mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: Blossom-compatible media upload (sprout-media crate + relay + desktop) (#66)
This commit is contained in:
728
Cargo.lock
generated
728
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ members = [
|
||||
"crates/sprout-test-client",
|
||||
"crates/sprout-admin",
|
||||
"crates/sprout-workflow",
|
||||
"crates/sprout-media",
|
||||
]
|
||||
exclude = ["desktop/src-tauri"]
|
||||
resolver = "2"
|
||||
@@ -105,3 +106,4 @@ sprout-mcp = { path = "crates/sprout-mcp" }
|
||||
sprout-proxy = { path = "crates/sprout-proxy" }
|
||||
sprout-huddle = { path = "crates/sprout-huddle" }
|
||||
sprout-workflow = { path = "crates/sprout-workflow" }
|
||||
sprout-media = { path = "crates/sprout-media" }
|
||||
|
||||
@@ -26,6 +26,8 @@ pub enum AuditAction {
|
||||
AuthFailure,
|
||||
/// A client exceeded the rate limit.
|
||||
RateLimitExceeded,
|
||||
/// A media file was uploaded via the Blossom endpoint.
|
||||
MediaUploaded,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
@@ -42,6 +44,7 @@ impl AuditAction {
|
||||
Self::AuthSuccess => "auth_success",
|
||||
Self::AuthFailure => "auth_failure",
|
||||
Self::RateLimitExceeded => "rate_limit_exceeded",
|
||||
Self::MediaUploaded => "media_uploaded",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +59,7 @@ impl AuditAction {
|
||||
Self::AuthSuccess,
|
||||
Self::AuthFailure,
|
||||
Self::RateLimitExceeded,
|
||||
Self::MediaUploaded,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,10 @@ pub const KIND_HUDDLE_TRACK_PUBLISHED: u32 = 48104;
|
||||
/// A huddle recording became available.
|
||||
pub const KIND_HUDDLE_RECORDING_AVAILABLE: u32 = 48105;
|
||||
|
||||
// Media (49000–49999)
|
||||
/// Internal kind for media upload audit entries. Not a relay event kind.
|
||||
pub const KIND_MEDIA_UPLOAD: u32 = 49001;
|
||||
|
||||
/// All registered kind constants — used for duplicate detection and iteration.
|
||||
pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_PROFILE,
|
||||
@@ -284,6 +288,7 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_HUDDLE_ENDED,
|
||||
KIND_HUDDLE_TRACK_PUBLISHED,
|
||||
KIND_HUDDLE_RECORDING_AVAILABLE,
|
||||
KIND_MEDIA_UPLOAD,
|
||||
];
|
||||
|
||||
/// Returns `true` if `kind` is in the ephemeral range (20000–29999).
|
||||
|
||||
30
crates/sprout-media/Cargo.toml
Normal file
30
crates/sprout-media/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "sprout-media"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Media storage, validation, and thumbnail generation for Sprout"
|
||||
|
||||
[dependencies]
|
||||
sprout-core = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
s3 = { version = "0.35", package = "rust-s3" }
|
||||
infer = "0.16"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||
blurhash = "0.2"
|
||||
imagesize = "0.13"
|
||||
bytes = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
280
crates/sprout-media/src/auth.rs
Normal file
280
crates/sprout-media/src/auth.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
//! Blossom kind:24242 auth verification (BUD-11 compliant).
|
||||
|
||||
use crate::error::MediaError;
|
||||
|
||||
/// Verify kind:24242 event validity per BUD-11:
|
||||
/// 1. Schnorr signature
|
||||
/// 2. kind == 24242
|
||||
/// 3. `t` tag == "upload"
|
||||
/// 4. `expiration` tag in the future
|
||||
/// 5. `created_at` in the past (with 5s clock-skew tolerance)
|
||||
/// 6. If `server` tags present, our domain must appear in at least one
|
||||
///
|
||||
/// Does NOT check the `x` tag — that requires the body hash, computed later.
|
||||
/// Call this BEFORE trusting the event's pubkey for scope resolution.
|
||||
pub fn verify_blossom_auth_event(
|
||||
auth_event: &nostr::Event,
|
||||
server_domain: Option<&str>,
|
||||
) -> Result<(), MediaError> {
|
||||
// 1. Verify Schnorr signature
|
||||
auth_event
|
||||
.verify()
|
||||
.map_err(|_| MediaError::InvalidSignature)?;
|
||||
|
||||
// 2. Kind must be 24242
|
||||
if auth_event.kind.as_u16() != 24242 {
|
||||
return Err(MediaError::InvalidAuthKind);
|
||||
}
|
||||
|
||||
// 2b. Content must be non-empty (BUD-11: "human readable string")
|
||||
if auth_event.content.trim().is_empty() {
|
||||
return Err(MediaError::InvalidAuthEvent);
|
||||
}
|
||||
|
||||
let mut found_t = false;
|
||||
let mut found_exp = false;
|
||||
let mut server_tags: Vec<&str> = Vec::new();
|
||||
let mut exp_value: u64 = 0;
|
||||
|
||||
for tag in auth_event.tags.iter() {
|
||||
let kind = tag.kind().to_string();
|
||||
match kind.as_str() {
|
||||
"t" => {
|
||||
if let Some(v) = tag.content() {
|
||||
if v != "upload" {
|
||||
return Err(MediaError::InvalidAuthVerb);
|
||||
}
|
||||
found_t = true;
|
||||
}
|
||||
}
|
||||
"expiration" => {
|
||||
if let Some(v) = tag.content() {
|
||||
exp_value = v.parse().unwrap_or(0);
|
||||
found_exp = true;
|
||||
}
|
||||
}
|
||||
"server" => {
|
||||
if let Some(v) = tag.content() {
|
||||
server_tags.push(v);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. t tag required
|
||||
if !found_t {
|
||||
return Err(MediaError::MissingTag("t"));
|
||||
}
|
||||
|
||||
// 4. Expiration must exist and be in the future
|
||||
if !found_exp {
|
||||
return Err(MediaError::MissingTag("expiration"));
|
||||
}
|
||||
let now = nostr::Timestamp::now().as_u64();
|
||||
if exp_value <= now {
|
||||
return Err(MediaError::TokenExpired);
|
||||
}
|
||||
|
||||
// 5. created_at must be recent: not in the future (5s tolerance) and not
|
||||
// older than 10 minutes. This bounds the replay window — even if the
|
||||
// expiration tag allows a longer lifetime, the token must have been
|
||||
// freshly minted.
|
||||
let created = auth_event.created_at.as_u64();
|
||||
if created > now + 5 {
|
||||
return Err(MediaError::TimestampOutOfWindow);
|
||||
}
|
||||
const MAX_AGE_SECS: u64 = 600; // 10 minutes
|
||||
if now > created + MAX_AGE_SECS {
|
||||
return Err(MediaError::TimestampOutOfWindow);
|
||||
}
|
||||
|
||||
// 6. Server tag enforcement (BUD-11 §5): if server tags present, our domain must appear.
|
||||
// Fail closed: if server_domain is unconfigured, reject tokens that carry server tags
|
||||
// rather than silently accepting them.
|
||||
if !server_tags.is_empty() {
|
||||
match server_domain {
|
||||
Some(domain) => {
|
||||
if !server_tags.contains(&domain) {
|
||||
return Err(MediaError::ServerMismatch);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Server tags present but we don't know our own domain — reject.
|
||||
return Err(MediaError::ServerMismatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify a kind:24242 Blossom upload auth event, including the x tag hash check.
|
||||
///
|
||||
/// Calls [`verify_blossom_auth_event`] first, then verifies that at least one
|
||||
/// `x` tag matches `sha256` (BUD-11 §6: "at least one x tag matches").
|
||||
pub fn verify_blossom_upload_auth(
|
||||
auth_event: &nostr::Event,
|
||||
sha256: &str,
|
||||
server_domain: Option<&str>,
|
||||
) -> Result<(), MediaError> {
|
||||
verify_blossom_auth_event(auth_event, server_domain)?;
|
||||
|
||||
// At least one x tag must match the body sha256 (BUD-11 §6)
|
||||
let has_matching_x = auth_event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(sha256)));
|
||||
|
||||
if !has_matching_x {
|
||||
return Err(MediaError::HashMismatch);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||||
|
||||
fn build_valid_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_u64();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let tags = vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &exp_str]).unwrap(),
|
||||
];
|
||||
EventBuilder::new(Kind::from(24242), "Upload sprout-media", tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_valid() {
|
||||
let keys = Keys::generate();
|
||||
let sha256 = "a".repeat(64);
|
||||
let event = build_valid_auth(&keys, &sha256);
|
||||
assert!(verify_blossom_upload_auth(&event, &sha256, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_auth_event_valid() {
|
||||
let keys = Keys::generate();
|
||||
let sha256 = "a".repeat(64);
|
||||
let event = build_valid_auth(&keys, &sha256);
|
||||
assert!(verify_blossom_auth_event(&event, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_hash_mismatch() {
|
||||
let keys = Keys::generate();
|
||||
let sha256 = "a".repeat(64);
|
||||
let event = build_valid_auth(&keys, &sha256);
|
||||
let wrong_hash = "b".repeat(64);
|
||||
assert!(matches!(
|
||||
verify_blossom_upload_auth(&event, &wrong_hash, None),
|
||||
Err(MediaError::HashMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_wrong_kind() {
|
||||
let keys = Keys::generate();
|
||||
let sha256 = "a".repeat(64);
|
||||
let now = Timestamp::now().as_u64();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let tags = vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &exp_str]).unwrap(),
|
||||
];
|
||||
let event = EventBuilder::new(Kind::from(27235), "wrong kind", tags)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
verify_blossom_upload_auth(&event, &sha256, None),
|
||||
Err(MediaError::InvalidAuthKind)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_multi_x_tags() {
|
||||
let keys = Keys::generate();
|
||||
let sha256 = "a".repeat(64);
|
||||
let other_hash = "b".repeat(64);
|
||||
let now = Timestamp::now().as_u64();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let tags = vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", &other_hash]).unwrap(),
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &exp_str]).unwrap(),
|
||||
];
|
||||
let event = EventBuilder::new(Kind::from(24242), "Upload multi-x", tags)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
// Should pass because at least one x tag matches
|
||||
assert!(verify_blossom_upload_auth(&event, &sha256, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_tag_enforcement() {
|
||||
let keys = Keys::generate();
|
||||
let sha256 = "a".repeat(64);
|
||||
let now = Timestamp::now().as_u64();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let tags = vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &exp_str]).unwrap(),
|
||||
Tag::parse(&["server", "other.example.com"]).unwrap(),
|
||||
];
|
||||
let event = EventBuilder::new(Kind::from(24242), "Upload scoped", tags)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
// Should fail — server tag present but doesn't match our domain
|
||||
assert!(matches!(
|
||||
verify_blossom_upload_auth(&event, &sha256, Some("sprout.example.com")),
|
||||
Err(MediaError::ServerMismatch)
|
||||
));
|
||||
// Should pass when our domain matches
|
||||
assert!(verify_blossom_upload_auth(&event, &sha256, Some("other.example.com")).is_ok());
|
||||
// Should fail when server_domain is None — fail closed
|
||||
assert!(matches!(
|
||||
verify_blossom_upload_auth(&event, &sha256, None),
|
||||
Err(MediaError::ServerMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_server_tags_always_passes() {
|
||||
let keys = Keys::generate();
|
||||
let sha256 = "a".repeat(64);
|
||||
let event = build_valid_auth(&keys, &sha256);
|
||||
// No server tags → passes regardless of our domain
|
||||
assert!(verify_blossom_upload_auth(&event, &sha256, Some("any.domain.com")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_content_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let sha256 = "a".repeat(64);
|
||||
let now = Timestamp::now().as_u64();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let tags = vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &exp_str]).unwrap(),
|
||||
];
|
||||
// Empty content — BUD-11 requires a human-readable string
|
||||
let event = EventBuilder::new(Kind::from(24242), "", tags)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
verify_blossom_auth_event(&event, None),
|
||||
Err(MediaError::InvalidAuthEvent)
|
||||
));
|
||||
}
|
||||
}
|
||||
51
crates/sprout-media/src/config.rs
Normal file
51
crates/sprout-media/src/config.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Media storage configuration.
|
||||
|
||||
/// Configuration for media storage (S3/MinIO).
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct MediaConfig {
|
||||
/// S3-compatible endpoint URL (e.g. "http://localhost:9000").
|
||||
pub s3_endpoint: String,
|
||||
/// S3 access key.
|
||||
pub s3_access_key: String,
|
||||
/// S3 secret key.
|
||||
pub s3_secret_key: String,
|
||||
/// S3 bucket name.
|
||||
pub s3_bucket: String,
|
||||
/// Maximum upload size for images (bytes). Default: 50 MB.
|
||||
pub max_image_bytes: u64,
|
||||
/// Maximum upload size for animated GIFs (bytes). Default: 10 MB.
|
||||
pub max_gif_bytes: u64,
|
||||
/// Public base URL for media URLs in BlobDescriptor (must include `/media` path).
|
||||
pub public_base_url: String,
|
||||
/// Server authority for BUD-11 server tag validation.
|
||||
/// Format: `host` for default ports, `host:port` for non-default ports.
|
||||
/// Examples: "sprout.example.com", "localhost:3000", "relay.example.com:8080".
|
||||
/// If None, auth events carrying `server` tags are rejected (fail-closed).
|
||||
/// Must match the authority the desktop signer derives from the relay URL.
|
||||
pub server_domain: Option<String>,
|
||||
}
|
||||
|
||||
impl MediaConfig {
|
||||
/// Validate configuration at startup. Returns an error on invalid config.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !self.public_base_url.ends_with("/media") {
|
||||
return Err(format!(
|
||||
"public_base_url must end with /media: got '{}'",
|
||||
self.public_base_url
|
||||
));
|
||||
}
|
||||
if self.public_base_url.ends_with('/') {
|
||||
return Err(format!(
|
||||
"public_base_url must not end with /: got '{}'",
|
||||
self.public_base_url
|
||||
));
|
||||
}
|
||||
if self.max_image_bytes == 0 {
|
||||
return Err("max_image_bytes must be > 0".to_string());
|
||||
}
|
||||
if self.max_gif_bytes == 0 || self.max_gif_bytes > self.max_image_bytes {
|
||||
return Err("max_gif_bytes must be > 0 and <= max_image_bytes".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
122
crates/sprout-media/src/error.rs
Normal file
122
crates/sprout-media/src/error.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Media error types.
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
/// Errors from media operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MediaError {
|
||||
#[error("unknown content type")]
|
||||
UnknownContentType,
|
||||
#[error("disallowed content type: {0}")]
|
||||
DisallowedContentType(String),
|
||||
#[error("file too large: {size} bytes (max {max})")]
|
||||
FileTooLarge { size: u64, max: u64 },
|
||||
#[error("image dimensions too large")]
|
||||
ImageTooLarge,
|
||||
#[error("invalid image data")]
|
||||
InvalidImage,
|
||||
#[error("invalid signature")]
|
||||
InvalidSignature,
|
||||
#[error("invalid auth event kind")]
|
||||
InvalidAuthKind,
|
||||
#[error("invalid auth verb")]
|
||||
InvalidAuthVerb,
|
||||
#[error("missing required tag: {0}")]
|
||||
MissingTag(&'static str),
|
||||
#[error("hash mismatch")]
|
||||
HashMismatch,
|
||||
#[error("server mismatch")]
|
||||
ServerMismatch,
|
||||
#[error("token expired")]
|
||||
TokenExpired,
|
||||
#[error("timestamp out of window")]
|
||||
TimestampOutOfWindow,
|
||||
#[error("storage error: {0}")]
|
||||
StorageError(String),
|
||||
#[error("internal error")]
|
||||
Internal,
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("missing authorization header")]
|
||||
MissingAuth,
|
||||
#[error("invalid authorization scheme")]
|
||||
InvalidAuthScheme,
|
||||
#[error("invalid base64 encoding")]
|
||||
InvalidBase64,
|
||||
#[error("invalid auth event")]
|
||||
InvalidAuthEvent,
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("insufficient scope")]
|
||||
InsufficientScope,
|
||||
#[error("token revoked")]
|
||||
TokenRevoked,
|
||||
#[error("pubkey mismatch")]
|
||||
PubkeyMismatch,
|
||||
}
|
||||
|
||||
impl From<image::ImageError> for MediaError {
|
||||
fn from(_: image::ImageError) -> Self {
|
||||
Self::InvalidImage
|
||||
}
|
||||
}
|
||||
|
||||
impl From<s3::error::S3Error> for MediaError {
|
||||
fn from(e: s3::error::S3Error) -> Self {
|
||||
Self::StorageError(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for MediaError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Self::StorageError(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for MediaError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, msg) = match &self {
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::DisallowedContentType(_) => {
|
||||
(StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string())
|
||||
}
|
||||
Self::FileTooLarge { .. } | Self::ImageTooLarge => {
|
||||
(StatusCode::PAYLOAD_TOO_LARGE, self.to_string())
|
||||
}
|
||||
// All authentication failures return the same generic 401 to prevent oracle enumeration.
|
||||
// InsufficientScope is intentionally 403 — it's an authorization (not authentication)
|
||||
// failure and is safe to distinguish since it requires a valid identity first.
|
||||
Self::MissingAuth
|
||||
| Self::InvalidAuthScheme
|
||||
| Self::InvalidBase64
|
||||
| Self::InvalidAuthEvent
|
||||
| Self::InvalidSignature
|
||||
| Self::InvalidAuthKind
|
||||
| Self::InvalidAuthVerb
|
||||
| Self::TokenExpired
|
||||
| Self::TimestampOutOfWindow
|
||||
| Self::Unauthorized
|
||||
| Self::TokenRevoked
|
||||
| Self::PubkeyMismatch
|
||||
| Self::HashMismatch
|
||||
| Self::ServerMismatch
|
||||
| Self::MissingTag(_) => {
|
||||
tracing::warn!(error = %self, "authentication failed");
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"authentication failed".to_string(),
|
||||
)
|
||||
}
|
||||
Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::UnknownContentType | Self::InvalidImage => {
|
||||
(StatusCode::BAD_REQUEST, self.to_string())
|
||||
}
|
||||
Self::StorageError(_) | Self::Internal => {
|
||||
tracing::error!(error = %self, "media storage error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
|
||||
}
|
||||
};
|
||||
(status, axum::Json(serde_json::json!({"error": msg}))).into_response()
|
||||
}
|
||||
}
|
||||
18
crates/sprout-media/src/lib.rs
Normal file
18
crates/sprout-media/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! Media storage, validation, and thumbnail generation for Sprout.
|
||||
//!
|
||||
//! Library crate — no Axum dependency for handlers. Axum handlers live in `sprout-relay`.
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod storage;
|
||||
pub mod thumbnail;
|
||||
pub mod types;
|
||||
pub mod upload;
|
||||
pub mod validation;
|
||||
|
||||
pub use config::MediaConfig;
|
||||
pub use error::MediaError;
|
||||
pub use storage::{BlobHeadMeta, BlobMeta, MediaStorage};
|
||||
pub use types::BlobDescriptor;
|
||||
pub use upload::process_upload;
|
||||
120
crates/sprout-media/src/storage.rs
Normal file
120
crates/sprout-media/src/storage.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
//! S3/MinIO storage client.
|
||||
|
||||
use s3::creds::Credentials;
|
||||
use s3::{Bucket, Region};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::MediaConfig;
|
||||
use crate::error::MediaError;
|
||||
|
||||
/// S3-compatible object storage client.
|
||||
pub struct MediaStorage {
|
||||
bucket: Box<Bucket>,
|
||||
}
|
||||
|
||||
impl MediaStorage {
|
||||
/// Create a new storage client from config.
|
||||
pub fn new(config: &MediaConfig) -> Result<Self, MediaError> {
|
||||
let region = Region::Custom {
|
||||
region: "us-east-1".into(),
|
||||
endpoint: config.s3_endpoint.clone(),
|
||||
};
|
||||
let creds = Credentials::new(
|
||||
Some(&config.s3_access_key),
|
||||
Some(&config.s3_secret_key),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| MediaError::StorageError(e.to_string()))?;
|
||||
let bucket = Bucket::new(&config.s3_bucket, region, creds)
|
||||
.map_err(|e| MediaError::StorageError(e.to_string()))?
|
||||
.with_path_style();
|
||||
Ok(Self { bucket })
|
||||
}
|
||||
|
||||
/// Store an object.
|
||||
pub async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), MediaError> {
|
||||
self.bucket
|
||||
.put_object_with_content_type(key, bytes, content_type)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve an object's bytes.
|
||||
pub async fn get(&self, key: &str) -> Result<Vec<u8>, MediaError> {
|
||||
match self.bucket.get_object(key).await {
|
||||
Ok(response) => Ok(response.to_vec()),
|
||||
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound),
|
||||
Err(e) => Err(MediaError::StorageError(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an object exists. Returns false on 404.
|
||||
pub async fn head(&self, key: &str) -> Result<bool, MediaError> {
|
||||
match self.bucket.head_object(key).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(false),
|
||||
Err(e) => Err(MediaError::StorageError(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an object. Returns an error on failure — callers decide whether to propagate.
|
||||
pub async fn delete(&self, key: &str) -> Result<(), MediaError> {
|
||||
self.bucket
|
||||
.delete_object(key)
|
||||
.await
|
||||
.map_err(|e| MediaError::StorageError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// HEAD with metadata — returns Content-Length (size).
|
||||
pub async fn head_with_metadata(&self, key: &str) -> Result<Option<BlobHeadMeta>, MediaError> {
|
||||
match self.bucket.head_object(key).await {
|
||||
Ok((result, _)) => Ok(Some(BlobHeadMeta {
|
||||
size: result.content_length.unwrap_or(0) as u64,
|
||||
})),
|
||||
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(None),
|
||||
Err(e) => Err(MediaError::StorageError(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read sidecar JSON for a given sha256 (bare hash, no extension).
|
||||
pub async fn get_sidecar(&self, sha256: &str) -> Result<BlobMeta, MediaError> {
|
||||
let key = format!("_meta/{sha256}.json");
|
||||
let resp = self.bucket.get_object(&key).await?;
|
||||
let meta: BlobMeta = serde_json::from_slice(&resp.to_vec())?;
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// Convenience: read just the MIME type from the sidecar.
|
||||
pub async fn read_sidecar_mime(&self, sha256_ext: &str) -> Option<String> {
|
||||
let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext);
|
||||
self.get_sidecar(sha256).await.ok().map(|m| m.mime_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata returned by HEAD — just enough for BUD-01 response headers.
|
||||
pub struct BlobHeadMeta {
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Full blob metadata — stored as sidecar JSON in `_meta/{sha256}.json`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BlobMeta {
|
||||
/// Pixel dimensions ("WxH").
|
||||
pub dim: String,
|
||||
/// Blurhash string.
|
||||
pub blurhash: String,
|
||||
/// Full URL to thumbnail.
|
||||
pub thumb_url: String,
|
||||
/// File extension (e.g. "jpg").
|
||||
pub ext: String,
|
||||
/// MIME type (e.g. "image/jpeg").
|
||||
pub mime_type: String,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// Unix timestamp when the blob was first uploaded.
|
||||
#[serde(default)]
|
||||
pub uploaded_at: i64,
|
||||
}
|
||||
51
crates/sprout-media/src/thumbnail.rs
Normal file
51
crates/sprout-media/src/thumbnail.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Synchronous thumbnail generation and blurhash encoding.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use image::ImageFormat;
|
||||
|
||||
use crate::config::MediaConfig;
|
||||
use crate::error::MediaError;
|
||||
use crate::storage::BlobMeta;
|
||||
|
||||
/// Generate thumbnail and blurhash from image bytes (CPU-bound, sync).
|
||||
///
|
||||
/// Returns `(metadata, optional thumbnail JPEG bytes)`.
|
||||
/// Caller handles S3 writes after `spawn_blocking` returns.
|
||||
pub fn generate_image_metadata_sync(
|
||||
config: &MediaConfig,
|
||||
sha256: &str,
|
||||
bytes: &[u8],
|
||||
mime: &str,
|
||||
ext: &str,
|
||||
) -> Result<(BlobMeta, Option<Vec<u8>>), MediaError> {
|
||||
if !mime.starts_with("image/") {
|
||||
return Ok((BlobMeta::default(), None));
|
||||
}
|
||||
|
||||
let img = image::load_from_memory(bytes)?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
|
||||
// Thumbnail: 320px max dimension, preserve aspect ratio
|
||||
let thumb = img.thumbnail(320, 320);
|
||||
let mut thumb_bytes = Vec::new();
|
||||
thumb.write_to(&mut Cursor::new(&mut thumb_bytes), ImageFormat::Jpeg)?;
|
||||
|
||||
// Blurhash from thumbnail (faster than full image)
|
||||
let rgba = thumb.to_rgba8();
|
||||
let bh =
|
||||
blurhash::encode(4, 3, thumb.width(), thumb.height(), rgba.as_raw()).unwrap_or_default();
|
||||
|
||||
Ok((
|
||||
BlobMeta {
|
||||
dim: format!("{w}x{h}"),
|
||||
blurhash: bh,
|
||||
thumb_url: format!("{}/{sha256}.thumb.jpg", config.public_base_url),
|
||||
ext: ext.to_string(),
|
||||
mime_type: mime.to_string(),
|
||||
size: bytes.len() as u64,
|
||||
..BlobMeta::default() // uploaded_at set by caller (process_upload)
|
||||
},
|
||||
Some(thumb_bytes),
|
||||
))
|
||||
}
|
||||
28
crates/sprout-media/src/types.rs
Normal file
28
crates/sprout-media/src/types.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! Blossom BUD-02 response types.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Blossom BlobDescriptor — returned by PUT /media/upload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlobDescriptor {
|
||||
/// Full URL to the blob.
|
||||
pub url: String,
|
||||
/// SHA-256 hex hash (64 chars).
|
||||
pub sha256: String,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// MIME type.
|
||||
#[serde(rename = "type")]
|
||||
pub mime_type: String,
|
||||
/// Unix timestamp of upload.
|
||||
pub uploaded: i64,
|
||||
/// Pixel dimensions ("WxH").
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dim: Option<String>,
|
||||
/// Blurhash string.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blurhash: Option<String>,
|
||||
/// Thumbnail URL.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb: Option<String>,
|
||||
}
|
||||
142
crates/sprout-media/src/upload.rs
Normal file
142
crates/sprout-media/src/upload.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! Upload pipeline — validate, store, thumbnail, sidecar.
|
||||
|
||||
use bytes::Bytes;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::auth::verify_blossom_upload_auth;
|
||||
use crate::config::MediaConfig;
|
||||
use crate::error::MediaError;
|
||||
use crate::storage::{BlobMeta, MediaStorage};
|
||||
use crate::thumbnail::generate_image_metadata_sync;
|
||||
use crate::types::BlobDescriptor;
|
||||
use crate::validation::{mime_to_ext, validate_content};
|
||||
|
||||
/// Process an upload end-to-end: validate, store, thumbnail, return descriptor.
|
||||
pub async fn process_upload(
|
||||
storage: &MediaStorage,
|
||||
config: &MediaConfig,
|
||||
auth_event: &nostr::Event,
|
||||
body: Bytes,
|
||||
) -> Result<BlobDescriptor, MediaError> {
|
||||
// CPU-bound: validate content, compute hash, verify auth
|
||||
let auth = auth_event.clone();
|
||||
let bytes = body.clone();
|
||||
let cfg = config.clone();
|
||||
let (mime, sha256, ext) = tokio::task::spawn_blocking(move || -> Result<_, MediaError> {
|
||||
let mime = validate_content(&bytes, &cfg)?;
|
||||
let sha256 = hex::encode(Sha256::digest(&bytes));
|
||||
let ext = mime_to_ext(&mime).to_string();
|
||||
verify_blossom_upload_auth(&auth, &sha256, cfg.server_domain.as_deref())?;
|
||||
Ok((mime, sha256, ext))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| MediaError::Internal)??;
|
||||
|
||||
let key = format!("{sha256}.{ext}");
|
||||
let meta_key = format!("_meta/{sha256}.json"); // used in idempotency check below
|
||||
|
||||
// Idempotent: check BOTH sidecar AND blob exist before short-circuiting.
|
||||
// If sidecar exists but blob is missing, fall through to re-upload.
|
||||
let sidecar_exists = storage.head(&meta_key).await?;
|
||||
let blob_exists = storage.head(&key).await?;
|
||||
if sidecar_exists && blob_exists {
|
||||
let meta = storage.get_sidecar(&sha256).await?;
|
||||
return Ok(build_descriptor(
|
||||
config,
|
||||
&sha256,
|
||||
&ext,
|
||||
&mime,
|
||||
body.len() as u64,
|
||||
Some(&meta),
|
||||
meta.uploaded_at,
|
||||
));
|
||||
}
|
||||
|
||||
// Compute uploaded_at once — single source of truth for sidecar and response.
|
||||
let uploaded_at = chrono::Utc::now().timestamp();
|
||||
|
||||
// Store blob first, then generate metadata.
|
||||
// On failure we intentionally do NOT delete the orphan blob — concurrent
|
||||
// uploads of the same hash could race and delete a blob that another
|
||||
// request is about to reference via its sidecar. Orphan blobs are
|
||||
// content-addressed and bounded by the upload size limit, so the storage
|
||||
// cost is negligible. A V2 background GC job can sweep blobs with no
|
||||
// matching sidecar after a grace period.
|
||||
storage.put(&key, &body, &mime).await?;
|
||||
|
||||
match generate_and_store_metadata(storage, config, &sha256, &ext, &mime, &body, uploaded_at)
|
||||
.await
|
||||
{
|
||||
Ok(meta) => Ok(build_descriptor(
|
||||
config,
|
||||
&sha256,
|
||||
&ext,
|
||||
&mime,
|
||||
body.len() as u64,
|
||||
Some(&meta),
|
||||
uploaded_at,
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::warn!(sha256 = %sha256, "metadata generation failed; orphan blob left for GC");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate thumbnail, blurhash, and sidecar metadata, then store them.
|
||||
/// Returns the completed [`BlobMeta`] on success.
|
||||
async fn generate_and_store_metadata(
|
||||
storage: &MediaStorage,
|
||||
config: &MediaConfig,
|
||||
sha256: &str,
|
||||
ext: &str,
|
||||
mime: &str,
|
||||
body: &Bytes,
|
||||
uploaded_at: i64,
|
||||
) -> Result<BlobMeta, MediaError> {
|
||||
let body_ref = body.clone();
|
||||
let mime_ref = mime.to_string();
|
||||
let ext_ref = ext.to_string();
|
||||
let sha256_ref = sha256.to_string();
|
||||
let cfg_ref = config.clone();
|
||||
let (mut meta, thumb_bytes) = tokio::task::spawn_blocking(move || {
|
||||
generate_image_metadata_sync(&cfg_ref, &sha256_ref, &body_ref, &mime_ref, &ext_ref)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| MediaError::Internal)??;
|
||||
|
||||
meta.uploaded_at = uploaded_at;
|
||||
|
||||
if let Some(ref tb) = thumb_bytes {
|
||||
let thumb_key = format!("{sha256}.thumb.jpg");
|
||||
storage.put(&thumb_key, tb, "image/jpeg").await?;
|
||||
}
|
||||
|
||||
let meta_key = format!("_meta/{sha256}.json");
|
||||
let meta_json = serde_json::to_vec(&meta)?;
|
||||
storage
|
||||
.put(&meta_key, &meta_json, "application/json")
|
||||
.await?;
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
fn build_descriptor(
|
||||
config: &MediaConfig,
|
||||
sha256: &str,
|
||||
ext: &str,
|
||||
mime: &str,
|
||||
size: u64,
|
||||
meta: Option<&BlobMeta>,
|
||||
uploaded_at: i64,
|
||||
) -> BlobDescriptor {
|
||||
BlobDescriptor {
|
||||
url: format!("{}/{sha256}.{ext}", config.public_base_url),
|
||||
sha256: sha256.to_string(),
|
||||
size,
|
||||
mime_type: mime.to_string(),
|
||||
uploaded: uploaded_at,
|
||||
dim: meta.map(|m| m.dim.clone()),
|
||||
blurhash: meta.map(|m| m.blurhash.clone()),
|
||||
thumb: meta.map(|m| m.thumb_url.clone()),
|
||||
}
|
||||
}
|
||||
166
crates/sprout-media/src/validation.rs
Normal file
166
crates/sprout-media/src/validation.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! Content validation — magic bytes, allowlist, size, image bomb protection.
|
||||
|
||||
use crate::config::MediaConfig;
|
||||
use crate::error::MediaError;
|
||||
|
||||
/// V1: images only. Video deferred to v2.
|
||||
const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||
|
||||
/// Validate uploaded bytes: magic bytes, allowlist, size, pixel dimensions.
|
||||
pub fn validate_content(bytes: &[u8], config: &MediaConfig) -> Result<String, MediaError> {
|
||||
// 1. Magic bytes — never trust Content-Type header
|
||||
let mime = infer::get(bytes)
|
||||
.map(|t| t.mime_type().to_string())
|
||||
.ok_or(MediaError::UnknownContentType)?;
|
||||
|
||||
// 2. Allowlist (SVG, PDF, executables all rejected)
|
||||
if !ALLOWED_MIME_TYPES.contains(&mime.as_str()) {
|
||||
return Err(MediaError::DisallowedContentType(mime));
|
||||
}
|
||||
|
||||
// 3. Size — GIF-specific cap (animated GIFs are CPU-intensive)
|
||||
let max = if mime == "image/gif" {
|
||||
config.max_gif_bytes
|
||||
} else {
|
||||
config.max_image_bytes
|
||||
};
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(MediaError::FileTooLarge {
|
||||
size: bytes.len() as u64,
|
||||
max,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Image bomb — check pixel dimensions before full decode.
|
||||
// Fail closed for all accepted types: imagesize supports JPEG, PNG, GIF, WebP.
|
||||
// If dimensions can't be parsed, reject — don't let unknown-geometry images
|
||||
// reach the full decoder in thumbnail generation.
|
||||
const MAX_PIXELS: u64 = 25_000_000; // 25 megapixels — 100MB max RGBA decode
|
||||
let size = imagesize::blob_size(bytes).map_err(|_| MediaError::InvalidImage)?;
|
||||
if (size.width as u64) * (size.height as u64) > MAX_PIXELS {
|
||||
return Err(MediaError::ImageTooLarge);
|
||||
}
|
||||
|
||||
Ok(mime)
|
||||
}
|
||||
|
||||
/// Map MIME type to file extension.
|
||||
pub fn mime_to_ext(mime: &str) -> &'static str {
|
||||
match mime {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/png" => "png",
|
||||
"image/gif" => "gif",
|
||||
"image/webp" => "webp",
|
||||
_ => "bin",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config() -> MediaConfig {
|
||||
MediaConfig {
|
||||
s3_endpoint: String::new(),
|
||||
s3_access_key: String::new(),
|
||||
s3_secret_key: String::new(),
|
||||
s3_bucket: String::new(),
|
||||
max_image_bytes: 50 * 1024 * 1024,
|
||||
max_gif_bytes: 10 * 1024 * 1024,
|
||||
public_base_url: String::new(),
|
||||
server_domain: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal valid JPEG: SOI + APP0 + SOF0 (1x1px).
|
||||
// SOF0 is required for imagesize to parse dimensions (fail-closed check).
|
||||
const TINY_JPEG: &[u8] = &[
|
||||
// SOI
|
||||
0xFF, 0xD8, // APP0 (JFIF marker)
|
||||
0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00,
|
||||
0x01, 0x00, 0x00, // SOF0: precision=8, height=1, width=1, components=1
|
||||
0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00,
|
||||
// EOI
|
||||
0xFF, 0xD9,
|
||||
];
|
||||
|
||||
// Minimal PNG header
|
||||
const TINY_PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn test_validate_jpeg() {
|
||||
let config = test_config();
|
||||
let result = validate_content(TINY_JPEG, &config);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "image/jpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_png() {
|
||||
let config = test_config();
|
||||
let result = validate_content(TINY_PNG, &config);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "image/png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_svg_rejected() {
|
||||
let config = test_config();
|
||||
// SVG starts with XML declaration — infer won't detect it as image
|
||||
let svg = b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
|
||||
let result = validate_content(svg, &config);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_oversized() {
|
||||
let mut config = test_config();
|
||||
config.max_image_bytes = 10; // 10 bytes max
|
||||
let result = validate_content(TINY_JPEG, &config);
|
||||
assert!(matches!(result, Err(MediaError::FileTooLarge { .. })));
|
||||
}
|
||||
|
||||
// Minimal valid GIF89a (1x1 pixel) — full logical screen descriptor so imagesize can parse.
|
||||
const TINY_GIF: &[u8] = &[
|
||||
// Header
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
// Logical Screen Descriptor: width=1, height=1, flags, bgcolor, aspect
|
||||
0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00,
|
||||
// Global Color Table (2 colors: white, black)
|
||||
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // Image Descriptor
|
||||
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Data
|
||||
0x02, 0x02, 0x4C, 0x01, 0x00, // Trailer
|
||||
0x3B,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn test_validate_gif_cap() {
|
||||
let mut config = test_config();
|
||||
config.max_gif_bytes = 5; // tiny cap
|
||||
config.max_image_bytes = 50 * 1024 * 1024;
|
||||
let result = validate_content(TINY_GIF, &config);
|
||||
assert!(matches!(result, Err(MediaError::FileTooLarge { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_gif_ok() {
|
||||
let config = test_config();
|
||||
let result = validate_content(TINY_GIF, &config);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "image/gif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mime_to_ext() {
|
||||
assert_eq!(mime_to_ext("image/jpeg"), "jpg");
|
||||
assert_eq!(mime_to_ext("image/png"), "png");
|
||||
assert_eq!(mime_to_ext("image/gif"), "gif");
|
||||
assert_eq!(mime_to_ext("image/webp"), "webp");
|
||||
assert_eq!(mime_to_ext("application/pdf"), "bin");
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ redis = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
base64 = "0.22"
|
||||
sprout-workflow = { workspace = true, features = ["reqwest"] }
|
||||
sprout-media = { workspace = true }
|
||||
bytes = "1"
|
||||
infer = "0.16"
|
||||
serde_yaml = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
498
crates/sprout-relay/src/api/media.rs
Normal file
498
crates/sprout-relay/src/api/media.rs
Normal file
@@ -0,0 +1,498 @@
|
||||
//! Blossom-compatible media upload, retrieval, and existence check handlers.
|
||||
//!
|
||||
//! Routes:
|
||||
//! PUT /media/upload — BUD-02 upload (auth required)
|
||||
//! GET /media/{sha256_ext} — BUD-01 serve blob
|
||||
//! HEAD /media/{sha256_ext} — BUD-01 existence check
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{FromRequestParts, Path, State},
|
||||
http::{request::Parts, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sprout_audit::{AuditAction, NewAuditEntry};
|
||||
use sprout_auth::Scope;
|
||||
use sprout_media::{BlobDescriptor, MediaError};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Upload ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Axum extractor that validates Blossom auth + API token scopes from headers
|
||||
/// BEFORE the request body is read. This prevents unauthenticated clients from
|
||||
/// forcing the server to buffer up to 50MB of body data.
|
||||
///
|
||||
/// Axum processes `FromRequestParts` extractors before `FromRequest` (body)
|
||||
/// extractors, so auth rejection happens before any body buffering.
|
||||
pub(crate) struct AuthenticatedUpload {
|
||||
auth_event: nostr::Event,
|
||||
#[allow(dead_code)] // scopes validated in extractor; stored for future per-scope handler logic
|
||||
scopes: Vec<Scope>,
|
||||
}
|
||||
|
||||
impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
type Rejection = MediaError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let headers = &parts.headers;
|
||||
|
||||
// 1. Extract and validate Blossom auth event
|
||||
let auth_event = extract_blossom_auth(headers)?;
|
||||
sprout_media::auth::verify_blossom_auth_event(
|
||||
&auth_event,
|
||||
state.config.media.server_domain.as_deref(),
|
||||
)?;
|
||||
|
||||
// 2. Require X-SHA-256 header (BUD-11: mandatory for PUT /upload)
|
||||
let claimed_hash = headers
|
||||
.get("x-sha-256")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(MediaError::MissingTag("x-sha-256"))?;
|
||||
|
||||
// Validate format: exactly 64 lowercase hex characters
|
||||
if claimed_hash.len() != 64
|
||||
|| !claimed_hash
|
||||
.chars()
|
||||
.all(|c| matches!(c, '0'..='9' | 'a'..='f'))
|
||||
{
|
||||
return Err(MediaError::HashMismatch);
|
||||
}
|
||||
|
||||
// 3. Validate X-SHA-256 matches at least one x tag in the auth event
|
||||
let has_matching_x = auth_event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(claimed_hash)));
|
||||
if !has_matching_x {
|
||||
return Err(MediaError::HashMismatch);
|
||||
}
|
||||
|
||||
// 4. Resolve scopes (API token or dev mode)
|
||||
let scopes = resolve_upload_scopes(headers, state, &auth_event.pubkey).await?;
|
||||
sprout_auth::require_scope(&scopes, Scope::FilesWrite)
|
||||
.map_err(|_| MediaError::InsufficientScope)?;
|
||||
|
||||
Ok(AuthenticatedUpload { auth_event, scopes })
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /media/upload — Blossom BUD-02 upload.
|
||||
///
|
||||
/// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body
|
||||
/// is read, preventing unauthenticated clients from forcing body buffering.
|
||||
// AuthenticatedUpload is pub(crate) — it's an internal extractor type, never
|
||||
// exposed outside this crate. The warning is benign: axum resolves it at
|
||||
// compile time via trait bounds, not by name.
|
||||
#[allow(private_interfaces)]
|
||||
///
|
||||
/// Expects:
|
||||
/// - `Authorization: Nostr <base64(kind:24242 event)>` — Blossom auth
|
||||
/// - `X-SHA-256: <hex>` — Required per BUD-11
|
||||
/// - `X-Auth-Token: sprout_*` — API token for scope resolution (optional in dev mode)
|
||||
/// - Raw binary body (the file bytes)
|
||||
///
|
||||
/// Returns a [`BlobDescriptor`] JSON on success.
|
||||
// TODO(v2): Add per-pubkey upload rate limiting and storage quotas to prevent
|
||||
// bandwidth/storage exhaustion from authenticated callers. Currently mitigated by
|
||||
// auth requirement (API token + Blossom signature) and body size limit.
|
||||
pub async fn upload_blob(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth: AuthenticatedUpload,
|
||||
body: Bytes,
|
||||
) -> Result<Json<BlobDescriptor>, MediaError> {
|
||||
let descriptor = sprout_media::process_upload(
|
||||
&state.media_storage,
|
||||
&state.config.media,
|
||||
&auth.auth_event,
|
||||
body,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Fire-and-forget audit — never block the response on audit I/O.
|
||||
let audit = state.audit.clone();
|
||||
let desc = descriptor.clone();
|
||||
let uploader = auth.auth_event.pubkey.to_hex();
|
||||
tokio::spawn(async move {
|
||||
let _ = audit
|
||||
.log(NewAuditEntry {
|
||||
event_id: desc.sha256.clone(),
|
||||
event_kind: sprout_core::kind::KIND_MEDIA_UPLOAD,
|
||||
actor_pubkey: uploader,
|
||||
action: AuditAction::MediaUploaded,
|
||||
channel_id: None,
|
||||
metadata: serde_json::json!({
|
||||
"sha256": desc.sha256,
|
||||
"size": desc.size,
|
||||
"mime": desc.mime_type,
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(Json(descriptor))
|
||||
}
|
||||
|
||||
// ── Serve ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Validate that sha256_ext is a safe path segment.
|
||||
///
|
||||
/// Accepted forms (max 3 segments):
|
||||
/// - `{sha256}` — bare 64-char lowercase hex
|
||||
/// - `{sha256}.{ext}` — hash + extension
|
||||
/// - `{sha256}.thumb.jpg` — hash + thumb variant (always JPEG)
|
||||
///
|
||||
/// Where `{ext}` ∈ {"jpg", "png", "gif", "webp"} for primary blobs (uploads canonicalize to .jpg, not .jpeg).
|
||||
/// Rejects path traversal, leading underscores, and any non-hex first segment.
|
||||
fn validate_media_path(sha256_ext: &str) -> Result<(), MediaError> {
|
||||
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
|
||||
|
||||
let segments: Vec<&str> = sha256_ext.split('.').collect();
|
||||
|
||||
// 1–3 segments only (hash, optional thumb, optional ext)
|
||||
if segments.is_empty() || segments.len() > 3 {
|
||||
return Err(MediaError::NotFound);
|
||||
}
|
||||
|
||||
// First segment must be exactly 64 lowercase hex chars (SHA-256)
|
||||
let hash = segments[0];
|
||||
if hash.len() != 64 || !hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) {
|
||||
return Err(MediaError::NotFound);
|
||||
}
|
||||
|
||||
// Validate remaining segments
|
||||
match segments.len() {
|
||||
1 => {} // bare hash — ok
|
||||
2 => {
|
||||
// {hash}.{ext}
|
||||
if !ALLOWED_EXTS.contains(&segments[1]) {
|
||||
return Err(MediaError::NotFound);
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// {hash}.thumb.jpg — thumbnails are always JPEG
|
||||
if segments[1] != "thumb" || segments[2] != "jpg" {
|
||||
return Err(MediaError::NotFound);
|
||||
}
|
||||
}
|
||||
_ => return Err(MediaError::NotFound),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GET /media/{sha256_ext} — Blossom BUD-01 serve blob.
|
||||
///
|
||||
/// `sha256_ext` is either:
|
||||
/// - `<sha256>.<ext>` — direct key (e.g. `abc123.jpg`)
|
||||
/// - `<sha256>` — bare hash; extension resolved from sidecar
|
||||
/// - `<sha256>.thumb.jpg` — thumbnail variant
|
||||
pub async fn get_blob(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(sha256_ext): Path<String>,
|
||||
) -> Result<Response, MediaError> {
|
||||
validate_media_path(&sha256_ext)?;
|
||||
|
||||
// Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative.
|
||||
let content_type = if sha256_ext.ends_with(".thumb.jpg") {
|
||||
let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext);
|
||||
let _ = state
|
||||
.media_storage
|
||||
.read_sidecar_mime(parent_hash)
|
||||
.await
|
||||
.ok_or(MediaError::NotFound)?;
|
||||
"image/jpeg".to_string()
|
||||
} else {
|
||||
// For explicit paths (hash.ext), verify the requested extension matches
|
||||
// the sidecar's canonical extension — sidecar is authoritative.
|
||||
let sidecar_mime = state
|
||||
.media_storage
|
||||
.read_sidecar_mime(&sha256_ext)
|
||||
.await
|
||||
.ok_or(MediaError::NotFound)?;
|
||||
if sha256_ext.contains('.') {
|
||||
let requested_ext = sha256_ext.rsplit('.').next().unwrap_or("");
|
||||
let sidecar = state
|
||||
.media_storage
|
||||
.get_sidecar(sha256_ext.split('.').next().unwrap_or(&sha256_ext))
|
||||
.await
|
||||
.map_err(|_| MediaError::NotFound)?;
|
||||
if requested_ext != sidecar.ext {
|
||||
return Err(MediaError::NotFound);
|
||||
}
|
||||
}
|
||||
sidecar_mime
|
||||
};
|
||||
|
||||
let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?;
|
||||
let bytes = state.media_storage.get(&key).await?;
|
||||
|
||||
Ok((
|
||||
[
|
||||
("content-type", content_type.as_str()),
|
||||
("cache-control", "public, max-age=31536000, immutable"),
|
||||
("content-disposition", "inline"),
|
||||
("content-security-policy", "default-src 'none'"),
|
||||
("x-content-type-options", "nosniff"),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// HEAD /media/{sha256_ext} — Blossom BUD-01 existence check.
|
||||
///
|
||||
/// Content-type is derived from the validated sidecar only — never from raw S3
|
||||
/// object metadata — to prevent MIME spoofing via tampered storage. If the sidecar
|
||||
/// is missing, we return 404 rather than fall back to untrusted metadata.
|
||||
pub async fn head_blob(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(sha256_ext): Path<String>,
|
||||
) -> Result<Response, MediaError> {
|
||||
validate_media_path(&sha256_ext)?;
|
||||
|
||||
// Sidecar gate FIRST — reject before any blob I/O.
|
||||
let content_type = if sha256_ext.ends_with(".thumb.jpg") {
|
||||
let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext);
|
||||
let _ = state
|
||||
.media_storage
|
||||
.read_sidecar_mime(parent_hash)
|
||||
.await
|
||||
.ok_or(MediaError::NotFound)?;
|
||||
"image/jpeg".to_string()
|
||||
} else {
|
||||
let sidecar_mime = state
|
||||
.media_storage
|
||||
.read_sidecar_mime(&sha256_ext)
|
||||
.await
|
||||
.ok_or(MediaError::NotFound)?;
|
||||
if sha256_ext.contains('.') {
|
||||
let requested_ext = sha256_ext.rsplit('.').next().unwrap_or("");
|
||||
let sidecar = state
|
||||
.media_storage
|
||||
.get_sidecar(sha256_ext.split('.').next().unwrap_or(&sha256_ext))
|
||||
.await
|
||||
.map_err(|_| MediaError::NotFound)?;
|
||||
if requested_ext != sidecar.ext {
|
||||
return Err(MediaError::NotFound);
|
||||
}
|
||||
}
|
||||
sidecar_mime
|
||||
};
|
||||
|
||||
let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?;
|
||||
match state.media_storage.head_with_metadata(&key).await? {
|
||||
Some(meta) => {
|
||||
let size_str = meta.size.to_string();
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", content_type.as_str()),
|
||||
("content-length", size_str.as_str()),
|
||||
("cache-control", "public, max-age=31536000, immutable"),
|
||||
],
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
None => Ok(StatusCode::NOT_FOUND.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the S3 key from a URL path segment.
|
||||
///
|
||||
/// - `sha256.ext` → used as-is (already validated by `validate_media_path`)
|
||||
/// - `sha256` (no dot) → read sidecar to get extension, return `sha256.ext`
|
||||
///
|
||||
/// Sidecar-derived extensions are validated against the allowlist to prevent
|
||||
/// object-key confusion if sidecar data is ever tampered with.
|
||||
async fn resolve_s3_key(
|
||||
storage: &sprout_media::MediaStorage,
|
||||
sha256_ext: &str,
|
||||
) -> Result<String, MediaError> {
|
||||
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
|
||||
|
||||
if sha256_ext.contains('.') {
|
||||
Ok(sha256_ext.to_string())
|
||||
} else {
|
||||
let sidecar = storage
|
||||
.get_sidecar(sha256_ext)
|
||||
.await
|
||||
.map_err(|_| MediaError::NotFound)?;
|
||||
// Validate sidecar ext — never trust storage as authoritative for path construction
|
||||
if !ALLOWED_EXTS.contains(&sidecar.ext.as_str()) {
|
||||
return Err(MediaError::NotFound);
|
||||
}
|
||||
Ok(format!("{}.{}", sha256_ext, sidecar.ext))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract and verify a kind:24242 Blossom auth event from the `Authorization` header.
|
||||
///
|
||||
/// Accepts both base64url (BUD-11 spec) and standard base64 (nostr-tools compat).
|
||||
fn extract_blossom_auth(headers: &HeaderMap) -> Result<nostr::Event, MediaError> {
|
||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||
|
||||
let header = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(MediaError::MissingAuth)?;
|
||||
|
||||
let token = header
|
||||
.strip_prefix("Nostr ")
|
||||
.ok_or(MediaError::InvalidAuthScheme)?;
|
||||
|
||||
let json_bytes = URL_SAFE_NO_PAD
|
||||
.decode(token)
|
||||
.or_else(|_| STANDARD.decode(token))
|
||||
.map_err(|_| MediaError::InvalidBase64)?;
|
||||
|
||||
let event: nostr::Event =
|
||||
serde_json::from_slice(&json_bytes).map_err(|_| MediaError::InvalidAuthEvent)?;
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Resolve permission scopes for an upload caller.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `X-Auth-Token: sprout_*` header — API token path (validates owner matches Blossom signer)
|
||||
/// 2. If `require_auth_token` is false (dev mode) — check pubkey allowlist, then grant file scopes
|
||||
async fn resolve_upload_scopes(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
blossom_pubkey: &nostr::PublicKey,
|
||||
) -> Result<Vec<Scope>, MediaError> {
|
||||
// 1. API token path — desktop sends Blossom auth in Authorization + token in X-Auth-Token.
|
||||
if let Some(token) = headers
|
||||
.get("x-auth-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|t| t.starts_with("sprout_"))
|
||||
{
|
||||
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
|
||||
let record = state
|
||||
.db
|
||||
.get_api_token_by_hash_including_revoked(&hash)
|
||||
.await
|
||||
.map_err(|_| MediaError::Unauthorized)?
|
||||
.ok_or(MediaError::Unauthorized)?;
|
||||
|
||||
if record.revoked_at.is_some() {
|
||||
return Err(MediaError::TokenRevoked);
|
||||
}
|
||||
if let Some(expires_at) = record.expires_at {
|
||||
if expires_at < chrono::Utc::now() {
|
||||
return Err(MediaError::TokenExpired);
|
||||
}
|
||||
}
|
||||
|
||||
// Token owner must match the Blossom signer — prevents token theft attacks.
|
||||
let blossom_bytes = blossom_pubkey.serialize().to_vec();
|
||||
if record.owner_pubkey != blossom_bytes {
|
||||
return Err(MediaError::PubkeyMismatch);
|
||||
}
|
||||
|
||||
return Ok(record
|
||||
.scopes
|
||||
.iter()
|
||||
.filter_map(|s| s.parse::<Scope>().ok())
|
||||
.collect());
|
||||
}
|
||||
|
||||
// 2. Dev mode: no API token required.
|
||||
if state.config.require_auth_token {
|
||||
return Err(MediaError::Unauthorized);
|
||||
}
|
||||
|
||||
// Dev mode is active — any valid Blossom signer can upload.
|
||||
// This must never be enabled in production.
|
||||
tracing::warn!(
|
||||
"dev mode upload: no API token required — ensure require_auth_token=true in production"
|
||||
);
|
||||
|
||||
// 3. Pubkey allowlist check (dev mode only).
|
||||
if state.config.pubkey_allowlist_enabled {
|
||||
let pubkey_bytes = blossom_pubkey.serialize().to_vec();
|
||||
if !state
|
||||
.db
|
||||
.is_pubkey_allowed(&pubkey_bytes)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(MediaError::Unauthorized);
|
||||
}
|
||||
}
|
||||
|
||||
// Dev mode: grant file scopes.
|
||||
Ok(vec![Scope::FilesRead, Scope::FilesWrite])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VALID_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
|
||||
#[test]
|
||||
fn test_validate_media_path_bare_hash() {
|
||||
assert!(validate_media_path(VALID_HASH).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_media_path_hash_ext() {
|
||||
for ext in &["jpg", "png", "gif", "webp"] {
|
||||
assert!(validate_media_path(&format!("{VALID_HASH}.{ext}")).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_media_path_thumb_jpg_only() {
|
||||
assert!(validate_media_path(&format!("{VALID_HASH}.thumb.jpg")).is_ok());
|
||||
// Other thumb extensions rejected — thumbnails are always JPEG
|
||||
assert!(validate_media_path(&format!("{VALID_HASH}.thumb.png")).is_err());
|
||||
assert!(validate_media_path(&format!("{VALID_HASH}.thumb.webp")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_media_path_rejects_bad_ext() {
|
||||
assert!(validate_media_path(&format!("{VALID_HASH}.svg")).is_err());
|
||||
assert!(validate_media_path(&format!("{VALID_HASH}.exe")).is_err());
|
||||
assert!(validate_media_path(&format!("{VALID_HASH}.pdf")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_media_path_rejects_short_hash() {
|
||||
assert!(validate_media_path("abc123.jpg").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_media_path_rejects_uppercase_hash() {
|
||||
let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789";
|
||||
assert!(validate_media_path(&format!("{upper}.jpg")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_media_path_rejects_traversal() {
|
||||
assert!(validate_media_path("../etc/passwd").is_err());
|
||||
assert!(validate_media_path(&format!("../{VALID_HASH}.jpg")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_media_path_rejects_too_many_segments() {
|
||||
assert!(validate_media_path(&format!("{VALID_HASH}.thumb.jpg.extra")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_media_path_rejects_empty() {
|
||||
assert!(validate_media_path("").is_err());
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,295 @@ use super::{
|
||||
internal_error, not_found,
|
||||
};
|
||||
|
||||
/// Validate imeta tags for correctness and safety.
|
||||
///
|
||||
/// Shared between REST (send_message) and WebSocket (handle_event) paths.
|
||||
/// Returns Ok(()) if all tags are valid, or a human-readable error string.
|
||||
pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result<(), String> {
|
||||
const ALLOWED_IMETA_KEYS: &[&str] = &[
|
||||
"url", "m", "x", "size", "dim", "blurhash", "alt", "thumb", "fallback",
|
||||
];
|
||||
const SINGLETON_KEYS: &[&str] = &["url", "m", "x", "size", "dim", "blurhash", "thumb", "alt"];
|
||||
const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||
|
||||
for tag in tags {
|
||||
if tag.first().map(|s| s.as_str()) != Some("imeta") {
|
||||
return Err("only imeta tags allowed in media_tags".into());
|
||||
}
|
||||
|
||||
let mut has_url = false;
|
||||
let mut has_m = false;
|
||||
let mut has_x = false;
|
||||
let mut has_size = false;
|
||||
let mut seen_keys = std::collections::HashSet::new();
|
||||
let mut url_value = String::new();
|
||||
let mut x_value = String::new();
|
||||
let mut m_value = String::new();
|
||||
let mut thumb_value = String::new();
|
||||
|
||||
for part in tag.iter().skip(1) {
|
||||
let mut parts = part.splitn(2, ' ');
|
||||
let key = parts.next().unwrap_or("");
|
||||
let value = parts.next().unwrap_or("");
|
||||
|
||||
if !ALLOWED_IMETA_KEYS.contains(&key) {
|
||||
return Err(format!("disallowed imeta key: {key}"));
|
||||
}
|
||||
if SINGLETON_KEYS.contains(&key) && !seen_keys.insert(key.to_string()) {
|
||||
return Err(format!("duplicate imeta key: {key}"));
|
||||
}
|
||||
|
||||
match key {
|
||||
"url" => {
|
||||
if !is_local_media_url(value, media_base_url) {
|
||||
return Err("imeta url must be a local /media/ path".into());
|
||||
}
|
||||
if value.contains(".thumb.") {
|
||||
return Err(
|
||||
"imeta url must not be a thumbnail path; use thumb field".into()
|
||||
);
|
||||
}
|
||||
url_value = value.to_string();
|
||||
has_url = true;
|
||||
}
|
||||
"m" => {
|
||||
if !ALLOWED_MIME.contains(&value) {
|
||||
return Err(
|
||||
"imeta m must be image/jpeg, image/png, image/gif, or image/webp"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
m_value = value.to_string();
|
||||
has_m = true;
|
||||
}
|
||||
"x" => {
|
||||
if value.len() != 64
|
||||
|| !value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
|
||||
{
|
||||
return Err("imeta x must be a 64-char lowercase hex SHA-256".into());
|
||||
}
|
||||
x_value = value.to_string();
|
||||
has_x = true;
|
||||
}
|
||||
"size" => {
|
||||
match value.parse::<u64>() {
|
||||
Ok(0) | Err(_) => {
|
||||
return Err("imeta size must be a positive integer".into())
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
has_size = true;
|
||||
}
|
||||
"thumb" => {
|
||||
if !is_local_media_url(value, media_base_url) || !value.ends_with(".thumb.jpg")
|
||||
{
|
||||
return Err("imeta thumb must be a local .thumb.jpg path".into());
|
||||
}
|
||||
thumb_value = value.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_url || !has_m || !has_x || !has_size {
|
||||
return Err("imeta tag must include url, m, x, and size".into());
|
||||
}
|
||||
|
||||
// Cross-check internal consistency: url hash must match x, url ext must match m.
|
||||
if let Some(hash_in_url) = extract_hash_from_media_url(&url_value) {
|
||||
if hash_in_url != x_value {
|
||||
return Err("imeta url hash does not match x".into());
|
||||
}
|
||||
}
|
||||
if let Some(ext_in_url) = extract_ext_from_media_url(&url_value) {
|
||||
let expected_ext = mime_to_canonical_ext(&m_value);
|
||||
if ext_in_url != expected_ext {
|
||||
return Err("imeta url extension does not match m".into());
|
||||
}
|
||||
}
|
||||
// Thumb hash must match x (same blob, different variant).
|
||||
if !thumb_value.is_empty() {
|
||||
if let Some(thumb_hash) = extract_hash_from_media_url(&thumb_value) {
|
||||
if thumb_hash != x_value {
|
||||
return Err("imeta thumb hash does not match x".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify that every imeta tag references a blob that actually exists in storage
|
||||
/// and that the claimed metadata (size, MIME) matches the sidecar.
|
||||
///
|
||||
/// Called after syntactic validation. Returns Ok(()) if all blobs exist and match,
|
||||
/// or a human-readable error string. This prevents clients from referencing
|
||||
/// nonexistent blobs or lying about size/MIME in imeta tags.
|
||||
pub async fn verify_imeta_blobs(
|
||||
tags: &[Vec<String>],
|
||||
storage: &sprout_media::MediaStorage,
|
||||
) -> Result<(), String> {
|
||||
for tag in tags {
|
||||
let mut x_value = String::new();
|
||||
let mut m_value = String::new();
|
||||
let mut size_value: u64 = 0;
|
||||
let mut thumb_value = String::new();
|
||||
|
||||
for part in tag.iter().skip(1) {
|
||||
let mut parts = part.splitn(2, ' ');
|
||||
let key = parts.next().unwrap_or("");
|
||||
let value = parts.next().unwrap_or("");
|
||||
match key {
|
||||
"x" => x_value = value.to_string(),
|
||||
"m" => m_value = value.to_string(),
|
||||
"size" => size_value = value.parse().unwrap_or(0),
|
||||
"thumb" => thumb_value = value.to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if x_value.is_empty() {
|
||||
continue; // syntactic validation already caught this
|
||||
}
|
||||
|
||||
// 1. Sidecar must exist — proves the upload pipeline completed.
|
||||
let sidecar = storage
|
||||
.get_sidecar(&x_value)
|
||||
.await
|
||||
.map_err(|_| format!("imeta references nonexistent blob: {x_value}"))?;
|
||||
|
||||
// 2. HEAD the actual blob object — sidecar alone is not proof of blob existence.
|
||||
let blob_key = format!("{x_value}.{}", sidecar.ext);
|
||||
let blob_exists = storage
|
||||
.head(&blob_key)
|
||||
.await
|
||||
.map_err(|e| format!("storage error checking blob {x_value}: {e}"))?;
|
||||
if !blob_exists {
|
||||
return Err(format!("imeta blob object missing in storage: {x_value}"));
|
||||
}
|
||||
|
||||
// 3. Cross-check claimed metadata against sidecar.
|
||||
if !m_value.is_empty() && sidecar.mime_type != m_value {
|
||||
return Err(format!(
|
||||
"imeta m ({m_value}) does not match stored MIME ({})",
|
||||
sidecar.mime_type
|
||||
));
|
||||
}
|
||||
if size_value > 0 && sidecar.size != size_value {
|
||||
return Err(format!(
|
||||
"imeta size ({size_value}) does not match stored size ({})",
|
||||
sidecar.size
|
||||
));
|
||||
}
|
||||
|
||||
// 4. If thumb is claimed, HEAD the thumbnail object too.
|
||||
if !thumb_value.is_empty() {
|
||||
let thumb_key = format!("{x_value}.thumb.jpg");
|
||||
let thumb_exists = storage
|
||||
.head(&thumb_key)
|
||||
.await
|
||||
.map_err(|e| format!("storage error checking thumbnail: {e}"))?;
|
||||
if !thumb_exists {
|
||||
return Err(format!(
|
||||
"imeta thumb references missing thumbnail: {x_value}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the 64-char hex hash from a `/media/{hash}.{ext}` or `/media/{hash}.thumb.jpg` URL.
|
||||
fn extract_hash_from_media_url(url: &str) -> Option<&str> {
|
||||
let after = url.rsplit("/media/").next()?;
|
||||
let hash = after.split('.').next()?;
|
||||
if hash.len() == 64 && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) {
|
||||
Some(hash)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the primary extension from a `/media/{hash}.{ext}` URL (not thumb).
|
||||
fn extract_ext_from_media_url(url: &str) -> Option<&str> {
|
||||
let after = url.rsplit("/media/").next()?;
|
||||
let segments: Vec<&str> = after.split('.').collect();
|
||||
if segments.len() == 2 {
|
||||
Some(segments[1])
|
||||
} else {
|
||||
None // bare hash or thumb — no primary ext to check
|
||||
}
|
||||
}
|
||||
|
||||
/// Map MIME to canonical extension (must match sprout-media's mime_to_ext).
|
||||
fn mime_to_canonical_ext(mime: &str) -> &str {
|
||||
match mime {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/png" => "png",
|
||||
"image/gif" => "gif",
|
||||
"image/webp" => "webp",
|
||||
_ => "bin",
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a URL references a valid local media blob path.
|
||||
///
|
||||
/// Accepts:
|
||||
/// - Relative: `/media/<sha256>.<ext>` or `/media/<sha256>.thumb.jpg`
|
||||
/// - Absolute: `<media_base_url>/<sha256>.<ext>` or `<media_base_url>/<sha256>.thumb.jpg`
|
||||
///
|
||||
/// Where sha256 is exactly 64 lowercase hex chars and ext is an allowed image extension.
|
||||
/// Thumbnails are always JPEG — only `.thumb.jpg` is accepted.
|
||||
/// Rejects percent-encoded traversal, query strings, fragments, and external origins.
|
||||
fn is_local_media_url(url: &str, media_base_url: &str) -> bool {
|
||||
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
|
||||
|
||||
// Extract the path portion after /media/
|
||||
let path_after_media = if let Some(rest) = url.strip_prefix("/media/") {
|
||||
rest
|
||||
} else {
|
||||
let base = media_base_url.trim_end_matches('/');
|
||||
let prefix = format!("{}/", base);
|
||||
if let Some(rest) = url.strip_prefix(&prefix) {
|
||||
rest
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Reject query strings and fragments
|
||||
if path_after_media.contains('?') || path_after_media.contains('#') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject percent-encoding (no legitimate blob path needs it)
|
||||
if path_after_media.contains('%') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse segments: must be {sha256}.{ext} or {sha256}.thumb.{ext}
|
||||
let segments: Vec<&str> = path_after_media.split('.').collect();
|
||||
match segments.len() {
|
||||
2 => {
|
||||
// {sha256}.{ext}
|
||||
let hash = segments[0];
|
||||
let ext = segments[1];
|
||||
hash.len() == 64
|
||||
&& hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
|
||||
&& ALLOWED_EXTS.contains(&ext)
|
||||
}
|
||||
3 => {
|
||||
// {sha256}.thumb.jpg — thumbnails are always JPEG
|
||||
let hash = segments[0];
|
||||
hash.len() == 64
|
||||
&& hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
|
||||
&& segments[1] == "thumb"
|
||||
&& segments[2] == "jpg"
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the effective message author from a stored event.
|
||||
///
|
||||
/// REST-created messages are signed by the relay keypair and attribute the real
|
||||
@@ -241,6 +530,10 @@ pub struct SendMessageBody {
|
||||
pub content: String,
|
||||
/// Hex-encoded event ID of the parent message (for replies).
|
||||
pub parent_event_id: Option<String>,
|
||||
/// Hex-encoded pubkeys to mention in the message (added as `p` tags).
|
||||
/// Distinct from the author-attribution `p` tag which is always added.
|
||||
#[serde(default)]
|
||||
pub mention_pubkeys: Vec<String>,
|
||||
/// When `true`, a reply is also surfaced in the channel feed (broadcast).
|
||||
#[serde(default)]
|
||||
pub broadcast_to_channel: bool,
|
||||
@@ -269,6 +562,10 @@ pub struct SendMessageBody {
|
||||
pub diff_truncated: Option<bool>,
|
||||
/// Plain-text alternative summary for clients that cannot render diffs.
|
||||
pub diff_alt: Option<String>,
|
||||
/// Media attachment tags (imeta only). Whitelist-validated.
|
||||
/// Each inner Vec is a single tag, e.g. `["imeta", "url https://...", "m image/jpeg"]`.
|
||||
#[serde(default)]
|
||||
pub media_tags: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Send a new channel message or reply to an existing thread.
|
||||
@@ -302,7 +599,8 @@ pub async fn send_message(
|
||||
return Err(api_error(StatusCode::FORBIDDEN, "channel is archived"));
|
||||
}
|
||||
|
||||
if body.content.trim().is_empty() {
|
||||
// Image-only messages: allow empty text content when media_tags are present.
|
||||
if body.content.trim().is_empty() && body.media_tags.is_empty() {
|
||||
return Err(api_error(StatusCode::BAD_REQUEST, "content is required"));
|
||||
}
|
||||
|
||||
@@ -409,6 +707,21 @@ pub async fn send_message(
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
];
|
||||
|
||||
// Mention p tags — distinct from the author-attribution p tag above.
|
||||
for mention_hex in &body.mention_pubkeys {
|
||||
// Validate hex format (32-byte pubkey = 64 hex chars)
|
||||
if mention_hex.len() == 64
|
||||
&& mention_hex
|
||||
.chars()
|
||||
.all(|c| matches!(c, '0'..='9' | 'a'..='f'))
|
||||
{
|
||||
tags.push(
|
||||
Tag::parse(&["p", mention_hex])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Thread reply tags (NIP-10 style).
|
||||
if let (Some(ref root_bytes), Some(ref parent_bytes)) = (&root_id_bytes, &parent_id_bytes) {
|
||||
let root_hex = nostr_hex::encode(root_bytes);
|
||||
@@ -445,6 +758,21 @@ pub async fn send_message(
|
||||
tags.extend(diff_tags);
|
||||
}
|
||||
|
||||
// Validate imeta tags: syntactic checks, then verify blobs exist in storage.
|
||||
validate_imeta_tags(&body.media_tags, &state.config.media.public_base_url)
|
||||
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &e))?;
|
||||
verify_imeta_blobs(&body.media_tags, &state.media_storage)
|
||||
.await
|
||||
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &e))?;
|
||||
|
||||
for tag in &body.media_tags {
|
||||
tags.push(
|
||||
Tag::parse(tag).map_err(|e| {
|
||||
api_error(StatusCode::BAD_REQUEST, &format!("invalid imeta tag: {e}"))
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
let event = EventBuilder::new(kind, &body.content, tags)
|
||||
.sign_with_keys(&state.relay_keypair)
|
||||
.map_err(|e| internal_error(&format!("event signing error: {e}")))?;
|
||||
@@ -884,3 +1212,158 @@ pub async fn get_thread(
|
||||
"next_cursor": next_cursor,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
const BASE: &str = "https://relay.example.com/media";
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_relative() {
|
||||
assert!(is_local_media_url(&format!("/media/{HASH}.jpg"), BASE));
|
||||
assert!(is_local_media_url(&format!("/media/{HASH}.png"), BASE));
|
||||
assert!(is_local_media_url(&format!("/media/{HASH}.gif"), BASE));
|
||||
assert!(is_local_media_url(&format!("/media/{HASH}.webp"), BASE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_absolute() {
|
||||
assert!(is_local_media_url(&format!("{BASE}/{HASH}.jpg"), BASE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_thumb_jpg_only() {
|
||||
assert!(is_local_media_url(
|
||||
&format!("/media/{HASH}.thumb.jpg"),
|
||||
BASE
|
||||
));
|
||||
// Other thumb extensions rejected
|
||||
assert!(!is_local_media_url(
|
||||
&format!("/media/{HASH}.thumb.png"),
|
||||
BASE
|
||||
));
|
||||
assert!(!is_local_media_url(
|
||||
&format!("/media/{HASH}.thumb.webp"),
|
||||
BASE
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_rejects_external() {
|
||||
assert!(!is_local_media_url(
|
||||
&format!("https://evil.com/media/{HASH}.jpg"),
|
||||
BASE
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_rejects_query_string() {
|
||||
assert!(!is_local_media_url(
|
||||
&format!("/media/{HASH}.jpg?foo=bar"),
|
||||
BASE
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_rejects_fragment() {
|
||||
assert!(!is_local_media_url(
|
||||
&format!("/media/{HASH}.jpg#frag"),
|
||||
BASE
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_rejects_percent_encoding() {
|
||||
assert!(!is_local_media_url(&format!("/media/{HASH}%2e.jpg"), BASE));
|
||||
assert!(!is_local_media_url("/media/%2e%2e/etc/passwd", BASE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_rejects_uppercase_hash() {
|
||||
let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789";
|
||||
assert!(!is_local_media_url(&format!("/media/{upper}.jpg"), BASE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_rejects_short_hash() {
|
||||
assert!(!is_local_media_url("/media/abc123.jpg", BASE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_rejects_bad_ext() {
|
||||
assert!(!is_local_media_url(&format!("/media/{HASH}.svg"), BASE));
|
||||
assert!(!is_local_media_url(&format!("/media/{HASH}.exe"), BASE));
|
||||
// .jpeg is not canonical — uploads produce .jpg only
|
||||
assert!(!is_local_media_url(&format!("/media/{HASH}.jpeg"), BASE));
|
||||
}
|
||||
|
||||
/// Thumb validation requires BOTH is_local_media_url AND .thumb.jpg suffix.
|
||||
/// A full-size blob URL must not be accepted as a thumbnail.
|
||||
#[test]
|
||||
fn test_thumb_must_be_thumb_jpg() {
|
||||
let thumb = format!("/media/{HASH}.thumb.jpg");
|
||||
let blob = format!("/media/{HASH}.jpg");
|
||||
assert!(is_local_media_url(&thumb, BASE) && thumb.ends_with(".thumb.jpg"));
|
||||
assert!(is_local_media_url(&blob, BASE));
|
||||
assert!(!blob.ends_with(".thumb.jpg"));
|
||||
}
|
||||
|
||||
// ── imeta consistency cross-checks ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_imeta_url_hash_must_match_x() {
|
||||
let other = "b".repeat(64);
|
||||
let tag = vec![
|
||||
"imeta".into(),
|
||||
format!("url /media/{HASH}.jpg"),
|
||||
"m image/jpeg".into(),
|
||||
format!("x {other}"),
|
||||
"size 100".into(),
|
||||
];
|
||||
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
|
||||
assert!(err.contains("url hash does not match x"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_imeta_url_ext_must_match_m() {
|
||||
let tag = vec![
|
||||
"imeta".into(),
|
||||
format!("url /media/{HASH}.png"),
|
||||
"m image/jpeg".into(),
|
||||
format!("x {HASH}"),
|
||||
"size 100".into(),
|
||||
];
|
||||
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
|
||||
assert!(err.contains("url extension does not match m"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_imeta_thumb_hash_must_match_x() {
|
||||
let other = "c".repeat(64);
|
||||
let tag = vec![
|
||||
"imeta".into(),
|
||||
format!("url /media/{HASH}.jpg"),
|
||||
"m image/jpeg".into(),
|
||||
format!("x {HASH}"),
|
||||
"size 100".into(),
|
||||
format!("thumb /media/{other}.thumb.jpg"),
|
||||
];
|
||||
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
|
||||
assert!(err.contains("thumb hash does not match x"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_imeta_consistent_tags_pass() {
|
||||
let tag = vec![
|
||||
"imeta".into(),
|
||||
format!("url /media/{HASH}.jpg"),
|
||||
"m image/jpeg".into(),
|
||||
format!("x {HASH}"),
|
||||
"size 100".into(),
|
||||
format!("thumb /media/{HASH}.thumb.jpg"),
|
||||
];
|
||||
assert!(validate_imeta_tags(&[tag], BASE).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ pub mod dms;
|
||||
pub mod events;
|
||||
/// Personalized home feed endpoint.
|
||||
pub mod feed;
|
||||
/// Blossom-compatible media upload, retrieval, and existence check endpoints.
|
||||
pub mod media;
|
||||
/// Channel membership endpoints.
|
||||
pub mod members;
|
||||
/// Message and thread endpoints.
|
||||
@@ -60,7 +62,10 @@ pub use dms::{add_dm_member_handler, list_dms_handler, open_dm_handler};
|
||||
pub use events::get_event;
|
||||
pub use feed::feed_handler;
|
||||
pub use members::{add_members, join_channel, leave_channel, list_members, remove_member};
|
||||
pub use messages::{delete_message, get_thread, list_messages, send_message};
|
||||
pub use messages::{
|
||||
delete_message, get_thread, list_messages, send_message, validate_imeta_tags,
|
||||
verify_imeta_blobs,
|
||||
};
|
||||
pub use presence::{presence_handler, set_presence_handler};
|
||||
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};
|
||||
pub use search::search_handler;
|
||||
|
||||
@@ -53,6 +53,8 @@ pub struct Config {
|
||||
/// API tokens or Okta JWTs bypass the allowlist entirely.
|
||||
/// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`.
|
||||
pub pubkey_allowlist_enabled: bool,
|
||||
/// Media storage configuration (S3/MinIO).
|
||||
pub media: sprout_media::MediaConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -135,6 +137,47 @@ impl Config {
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let media = sprout_media::MediaConfig {
|
||||
s3_endpoint: std::env::var("SPROUT_S3_ENDPOINT")
|
||||
.unwrap_or_else(|_| "http://localhost:9000".to_string()),
|
||||
s3_access_key: std::env::var("SPROUT_S3_ACCESS_KEY")
|
||||
.unwrap_or_else(|_| "sprout_dev".to_string()),
|
||||
s3_secret_key: std::env::var("SPROUT_S3_SECRET_KEY")
|
||||
.unwrap_or_else(|_| "sprout_dev_secret".to_string()),
|
||||
s3_bucket: std::env::var("SPROUT_S3_BUCKET")
|
||||
.unwrap_or_else(|_| "sprout-media".to_string()),
|
||||
max_image_bytes: std::env::var("SPROUT_MAX_IMAGE_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50 * 1024 * 1024),
|
||||
max_gif_bytes: std::env::var("SPROUT_MAX_GIF_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10 * 1024 * 1024),
|
||||
public_base_url: std::env::var("SPROUT_MEDIA_BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000/media".to_string()),
|
||||
server_domain: std::env::var("SPROUT_MEDIA_SERVER_DOMAIN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| {
|
||||
// Auto-derive from RELAY_URL so desktop uploads work out-of-the-box
|
||||
// without requiring an extra env var in dev mode.
|
||||
url::Url::parse(
|
||||
&relay_url
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://"),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|u| {
|
||||
let host = u.host_str()?.to_string();
|
||||
match u.port() {
|
||||
Some(p) => Some(format!("{host}:{p}")),
|
||||
None => Some(host),
|
||||
}
|
||||
})
|
||||
}),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
bind_addr,
|
||||
database_url,
|
||||
@@ -151,6 +194,7 @@ impl Config {
|
||||
relay_private_key,
|
||||
uds_path,
|
||||
pubkey_allowlist_enabled,
|
||||
media,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -187,4 +231,45 @@ mod tests {
|
||||
std::env::remove_var("SPROUT_BIND_ADDR");
|
||||
assert!(matches!(result, Err(ConfigError::InvalidBindAddr(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_domain_auto_derived_from_relay_url() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
// Clear explicit override so auto-derive kicks in
|
||||
std::env::remove_var("SPROUT_MEDIA_SERVER_DOMAIN");
|
||||
std::env::set_var("RELAY_URL", "ws://localhost:3000");
|
||||
let config = Config::from_env().expect("config");
|
||||
std::env::remove_var("RELAY_URL");
|
||||
assert_eq!(
|
||||
config.media.server_domain.as_deref(),
|
||||
Some("localhost:3000")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_domain_auto_derived_default_port() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::remove_var("SPROUT_MEDIA_SERVER_DOMAIN");
|
||||
std::env::set_var("RELAY_URL", "wss://relay.example.com");
|
||||
let config = Config::from_env().expect("config");
|
||||
std::env::remove_var("RELAY_URL");
|
||||
assert_eq!(
|
||||
config.media.server_domain.as_deref(),
|
||||
Some("relay.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_domain_explicit_override_wins() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var("SPROUT_MEDIA_SERVER_DOMAIN", "custom.example.com");
|
||||
std::env::set_var("RELAY_URL", "ws://localhost:3000");
|
||||
let config = Config::from_env().expect("config");
|
||||
std::env::remove_var("SPROUT_MEDIA_SERVER_DOMAIN");
|
||||
std::env::remove_var("RELAY_URL");
|
||||
assert_eq!(
|
||||
config.media.server_domain.as_deref(),
|
||||
Some("custom.example.com")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +360,40 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
|
||||
}
|
||||
}
|
||||
|
||||
// Validate imeta tags on WebSocket-submitted events (same rules as REST path).
|
||||
// Extract imeta tags from the event and validate them.
|
||||
let imeta_tags: Vec<Vec<String>> = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.kind().to_string() == "imeta")
|
||||
.map(|t| {
|
||||
// Use as_slice() to capture ALL tag elements — no index cap.
|
||||
// The first element is the tag kind ("imeta"), already included.
|
||||
t.as_slice().iter().map(|s| s.to_string()).collect()
|
||||
})
|
||||
.collect();
|
||||
if !imeta_tags.is_empty() {
|
||||
if let Err(e) =
|
||||
crate::api::validate_imeta_tags(&imeta_tags, &state.config.media.public_base_url)
|
||||
{
|
||||
conn.send(RelayMessage::ok(
|
||||
&event_id_hex,
|
||||
false,
|
||||
&format!("invalid: {e}"),
|
||||
));
|
||||
return;
|
||||
}
|
||||
// Verify referenced blobs exist in storage and metadata matches.
|
||||
if let Err(e) = crate::api::verify_imeta_blobs(&imeta_tags, &state.media_storage).await {
|
||||
conn.send(RelayMessage::ok(
|
||||
&event_id_hex,
|
||||
false,
|
||||
&format!("invalid: {e}"),
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let (stored_event, was_inserted) = match state.db.insert_event(&event, channel_id).await {
|
||||
Ok(result) => result,
|
||||
Err(sprout_db::DbError::AuthEventRejected) => {
|
||||
|
||||
@@ -99,6 +99,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
keys
|
||||
};
|
||||
|
||||
config
|
||||
.media
|
||||
.validate()
|
||||
.map_err(|e| anyhow::anyhow!("invalid media config: {e}"))?;
|
||||
let media_storage = sprout_media::MediaStorage::new(&config.media)
|
||||
.map_err(|e| anyhow::anyhow!("failed to initialize media storage: {e}"))?;
|
||||
info!("Media storage connected");
|
||||
|
||||
let state = Arc::new(AppState::new(
|
||||
config.clone(),
|
||||
db,
|
||||
@@ -109,6 +117,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
search,
|
||||
Arc::clone(&workflow_engine),
|
||||
relay_keypair,
|
||||
media_storage,
|
||||
));
|
||||
|
||||
// Wire the action sink — must happen after AppState (which creates
|
||||
|
||||
@@ -21,8 +21,28 @@ use crate::nip11::{relay_info_handler, RelayInfo};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration.
|
||||
///
|
||||
/// Uses a dual sub-router pattern so media routes can carry a 50 MB body limit
|
||||
/// while all other routes remain capped at 1 MB. Each sub-router attaches its
|
||||
/// own `RequestBodyLimitLayer` before merging; the outer layer adds tracing and
|
||||
/// CORS once over the combined router.
|
||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
// ── Media routes: body limit derived from config ─────────────────────────
|
||||
// Transport limit is max_image_bytes (50MB). GIFs have a stricter app-level cap (10MB)
|
||||
// enforced in validation.rs after MIME detection. This means GIF uploads up to 50MB are
|
||||
// buffered before rejection — acceptable for V1; streaming validation deferred to V2.
|
||||
let media_body_limit = state.config.media.max_image_bytes as usize;
|
||||
let media_router = Router::new()
|
||||
.route("/media/upload", put(api::media::upload_blob))
|
||||
.route(
|
||||
"/media/{sha256_ext}",
|
||||
get(api::media::get_blob).head(api::media::head_blob),
|
||||
)
|
||||
.layer(RequestBodyLimitLayer::new(media_body_limit))
|
||||
.with_state(state.clone());
|
||||
|
||||
// ── All other routes: 1 MB body limit ────────────────────────────────────
|
||||
let api_router = Router::new()
|
||||
.route("/", get(nip11_or_ws_handler))
|
||||
.route("/info", get(relay_info_handler))
|
||||
.route("/.well-known/nostr.json", get(api::nip05::nostr_nip05))
|
||||
@@ -145,11 +165,16 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.route("/api/users/batch", post(api::get_users_batch))
|
||||
// Feed route
|
||||
.route("/api/feed", get(api::feed_handler))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(build_cors_layer(&state.config.cors_origins))
|
||||
// Reject request bodies larger than 1 MB to prevent resource exhaustion.
|
||||
.layer(RequestBodyLimitLayer::new(1024 * 1024))
|
||||
.with_state(state)
|
||||
.with_state(state.clone());
|
||||
|
||||
// Merge — each sub-router carries its own body limit.
|
||||
// TraceLayer and CORS are applied once over the combined router.
|
||||
api_router
|
||||
.merge(media_router)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(build_cors_layer(&state.config.cors_origins))
|
||||
}
|
||||
|
||||
/// Content-negotiated: NIP-11 JSON for plain HTTP, WebSocket upgrade otherwise.
|
||||
|
||||
@@ -14,6 +14,7 @@ use deadpool_redis;
|
||||
use sprout_audit::AuditService;
|
||||
use sprout_auth::AuthService;
|
||||
use sprout_db::Db;
|
||||
use sprout_media::MediaStorage;
|
||||
use sprout_pubsub::PubSubManager;
|
||||
use sprout_search::SearchService;
|
||||
use sprout_workflow::WorkflowEngine;
|
||||
@@ -149,6 +150,8 @@ pub struct AppState {
|
||||
/// consumer skips them to avoid double delivery. Entries expire after
|
||||
/// 60 seconds via moka's TTL eviction — bounded regardless of subscriber health.
|
||||
pub local_event_ids: Arc<moka::sync::Cache<[u8; 32], ()>>,
|
||||
/// Media storage client (S3/MinIO).
|
||||
pub media_storage: Arc<MediaStorage>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -164,6 +167,7 @@ impl AppState {
|
||||
search: SearchService,
|
||||
workflow_engine: Arc<WorkflowEngine>,
|
||||
relay_keypair: nostr::Keys,
|
||||
media_storage: MediaStorage,
|
||||
) -> Self {
|
||||
let max_connections = config.max_connections;
|
||||
let max_concurrent_handlers = config.max_concurrent_handlers;
|
||||
@@ -189,6 +193,7 @@ impl AppState {
|
||||
.time_to_live(std::time::Duration::from_secs(60))
|
||||
.build(),
|
||||
),
|
||||
media_storage: Arc::new(media_storage),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,10 @@ uuid = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
hex = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
hex = "0.4"
|
||||
|
||||
[[bin]]
|
||||
name = "sprout-test-cli"
|
||||
|
||||
383
crates/sprout-test-client/tests/e2e_media.rs
Normal file
383
crates/sprout-test-client/tests/e2e_media.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
//! End-to-end media upload tests (Blossom / NIP-96 style).
|
||||
//!
|
||||
//! Requires: relay running at localhost:3000, MinIO running at localhost:9000.
|
||||
//! All tests are `#[ignore]` so they don't run in CI by default.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test -p sprout-test-client --test e2e_media -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Override the relay URL:
|
||||
//!
|
||||
//! ```text
|
||||
//! RELAY_HTTP_URL=http://localhost:3000 cargo test -p sprout-test-client --test e2e_media -- --ignored
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
||||
use reqwest::Client;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// ── URL helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.expect("failed to build HTTP client")
|
||||
}
|
||||
|
||||
// ── Blossom auth helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// Sign a kind:24242 Blossom upload auth event for the given sha256.
|
||||
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_u64();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let tags = vec![
|
||||
Tag::parse(&["t", "upload"]).expect("t tag"),
|
||||
Tag::parse(&["x", sha256]).expect("x tag"),
|
||||
Tag::parse(&["expiration", &exp_str]).expect("expiration tag"),
|
||||
];
|
||||
EventBuilder::new(Kind::from(24242), "Upload test", tags)
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign blossom auth")
|
||||
}
|
||||
|
||||
/// Build `Authorization: Nostr <base64url(json)>` header value.
|
||||
fn blossom_auth_header(event: &nostr::Event) -> String {
|
||||
format!(
|
||||
"Nostr {}",
|
||||
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
// ── Minimal 1×1 JPEG ─────────────────────────────────────────────────────────
|
||||
|
||||
/// A valid 1×1 red JPEG (339 bytes). Used for fast upload tests.
|
||||
fn tiny_jpeg() -> Vec<u8> {
|
||||
vec![
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
|
||||
0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
|
||||
0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
|
||||
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
|
||||
0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
|
||||
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
|
||||
0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
|
||||
0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05,
|
||||
0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
|
||||
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
|
||||
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A,
|
||||
0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
|
||||
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
|
||||
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
|
||||
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93,
|
||||
0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9,
|
||||
0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
|
||||
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
|
||||
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7,
|
||||
0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
|
||||
0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xFF, 0xD9,
|
||||
]
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Upload a tiny JPEG, then GET it back and verify the bytes match.
|
||||
/// Also checks BlobDescriptor fields and thumbnail endpoint.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_and_get() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
|
||||
println!("sha256: {sha256}");
|
||||
println!("relay: {}", relay_http_url());
|
||||
|
||||
// PUT /media/upload
|
||||
let auth = sign_blossom_auth(&keys, &sha256);
|
||||
let resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.header("X-SHA-256", &sha256)
|
||||
.body(jpeg.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload PUT failed");
|
||||
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
println!("PUT /media/upload → {status}: {body_text}");
|
||||
assert_eq!(status, 200, "upload should succeed");
|
||||
|
||||
// Parse BlobDescriptor
|
||||
let descriptor: serde_json::Value =
|
||||
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
|
||||
println!("BlobDescriptor: {descriptor:#}");
|
||||
|
||||
assert_eq!(
|
||||
descriptor["sha256"].as_str().unwrap(),
|
||||
sha256,
|
||||
"sha256 must match"
|
||||
);
|
||||
assert!(
|
||||
descriptor["url"].as_str().unwrap().contains(&sha256),
|
||||
"url must contain sha256"
|
||||
);
|
||||
assert!(
|
||||
descriptor["size"].as_u64().unwrap() > 0,
|
||||
"size must be positive"
|
||||
);
|
||||
assert!(
|
||||
descriptor["type"].as_str().is_some(),
|
||||
"mime type must be present"
|
||||
);
|
||||
// dim and blurhash are best-effort (image processing may not run on 1x1 JPEG)
|
||||
println!(
|
||||
"dim: {:?}, blurhash: {:?}",
|
||||
descriptor["dim"], descriptor["blurhash"]
|
||||
);
|
||||
|
||||
// GET /media/{sha256}.jpg — bytes must match
|
||||
let get_url = format!("{}/media/{sha256}.jpg", relay_http_url());
|
||||
let get_resp = client
|
||||
.get(&get_url)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET /media/{sha256}.jpg failed");
|
||||
assert_eq!(get_resp.status(), 200, "GET should return 200");
|
||||
let returned_bytes = get_resp.bytes().await.unwrap();
|
||||
assert_eq!(
|
||||
returned_bytes.as_ref(),
|
||||
jpeg.as_slice(),
|
||||
"GET must return original bytes"
|
||||
);
|
||||
|
||||
// HEAD /media/{sha256}.jpg — must return 200 with content-type
|
||||
let head_resp = client
|
||||
.head(&get_url)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD /media/{sha256}.jpg failed");
|
||||
assert_eq!(head_resp.status(), 200, "HEAD should return 200");
|
||||
assert!(
|
||||
head_resp.headers().get("content-type").is_some(),
|
||||
"HEAD must include content-type"
|
||||
);
|
||||
|
||||
// GET thumbnail — /media/{sha256}.thumb.jpg
|
||||
let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url());
|
||||
let thumb_resp = client
|
||||
.get(&thumb_url)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET thumbnail failed");
|
||||
println!("GET thumbnail → {}", thumb_resp.status());
|
||||
// Thumbnail may be same as original for 1x1 images — just check 200
|
||||
assert_eq!(thumb_resp.status(), 200, "thumbnail should return 200");
|
||||
}
|
||||
|
||||
/// Idempotency: uploading the same file twice returns the same BlobDescriptor.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_idempotent() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
|
||||
let upload = |keys: &Keys| {
|
||||
let auth = sign_blossom_auth(keys, &sha256);
|
||||
client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.header("X-SHA-256", sha256.clone())
|
||||
.body(jpeg.clone())
|
||||
.send()
|
||||
};
|
||||
|
||||
let r1: serde_json::Value = upload(&keys)
|
||||
.await
|
||||
.expect("first upload failed")
|
||||
.json()
|
||||
.await
|
||||
.expect("first descriptor parse");
|
||||
|
||||
// Second upload — different key, same content
|
||||
let keys2 = Keys::generate();
|
||||
let r2: serde_json::Value = upload(&keys2)
|
||||
.await
|
||||
.expect("second upload failed")
|
||||
.json()
|
||||
.await
|
||||
.expect("second descriptor parse");
|
||||
|
||||
assert_eq!(
|
||||
r1["sha256"], r2["sha256"],
|
||||
"sha256 must be identical on re-upload"
|
||||
);
|
||||
assert_eq!(r1["url"], r2["url"], "url must be identical on re-upload");
|
||||
}
|
||||
|
||||
/// Upload without an Authorization header must return 401.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_no_auth_returns_401() {
|
||||
let client = http_client();
|
||||
let jpeg = tiny_jpeg();
|
||||
|
||||
let resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.body(jpeg)
|
||||
.send()
|
||||
.await
|
||||
.expect("request failed");
|
||||
|
||||
println!("no-auth → {}", resp.status());
|
||||
assert_eq!(resp.status(), 401, "upload without auth must be 401");
|
||||
}
|
||||
|
||||
/// Upload without X-SHA-256 header must return 401 (BUD-11: mandatory).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_missing_x_sha256_returns_401() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
|
||||
let auth = sign_blossom_auth(&keys, &sha256);
|
||||
let resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
// Intentionally omit X-SHA-256
|
||||
.body(jpeg)
|
||||
.send()
|
||||
.await
|
||||
.expect("request failed");
|
||||
|
||||
println!("missing-x-sha256 → {}", resp.status());
|
||||
assert_eq!(resp.status(), 401, "upload without X-SHA-256 must be 401");
|
||||
}
|
||||
|
||||
/// Upload where the `x` tag sha256 doesn't match the actual body must return 401.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_hash_mismatch_returns_400() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let wrong_hash = "f".repeat(64); // definitely not the real sha256
|
||||
|
||||
let auth = sign_blossom_auth(&keys, &wrong_hash);
|
||||
let resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.header("X-SHA-256", &wrong_hash)
|
||||
.body(jpeg)
|
||||
.send()
|
||||
.await
|
||||
.expect("request failed");
|
||||
|
||||
println!("hash-mismatch → {}", resp.status());
|
||||
assert_eq!(resp.status(), 401, "hash mismatch must be 401");
|
||||
}
|
||||
|
||||
/// GET a sha256 that was never uploaded must return 404.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_get_nonexistent_returns_404() {
|
||||
let client = http_client();
|
||||
let missing_sha256 = "0".repeat(64);
|
||||
let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url());
|
||||
|
||||
let resp = client.get(&url).send().await.expect("GET failed");
|
||||
println!("missing blob → {}", resp.status());
|
||||
assert_eq!(resp.status(), 404, "missing blob must be 404");
|
||||
}
|
||||
|
||||
/// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var).
|
||||
/// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_real_image() {
|
||||
let image_path = match std::env::var("TEST_IMAGE_PATH") {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
println!("Skipping: TEST_IMAGE_PATH not set");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let bytes = std::fs::read(&image_path).expect("read image file");
|
||||
let sha256 = hex::encode(Sha256::digest(&bytes));
|
||||
let size = bytes.len();
|
||||
|
||||
println!("image: {image_path}");
|
||||
println!("size: {size} bytes");
|
||||
println!("sha256: {sha256}");
|
||||
|
||||
let auth = sign_blossom_auth(&keys, &sha256);
|
||||
let resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.header("X-SHA-256", &sha256)
|
||||
.body(bytes.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload PUT failed");
|
||||
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
println!("PUT /media/upload → {status}: {body_text}");
|
||||
assert_eq!(status, 200, "upload should succeed");
|
||||
|
||||
let descriptor: serde_json::Value =
|
||||
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
|
||||
println!("BlobDescriptor: {descriptor:#}");
|
||||
|
||||
assert_eq!(descriptor["sha256"].as_str().unwrap(), sha256);
|
||||
assert_eq!(descriptor["size"].as_u64().unwrap(), size as u64);
|
||||
assert!(descriptor["url"].as_str().unwrap().contains(&sha256));
|
||||
assert!(
|
||||
descriptor["dim"].as_str().is_some(),
|
||||
"real image should have dim"
|
||||
);
|
||||
assert!(
|
||||
descriptor["blurhash"].as_str().is_some(),
|
||||
"real image should have blurhash"
|
||||
);
|
||||
|
||||
// GET bytes back and verify
|
||||
let get_url = descriptor["url"].as_str().unwrap();
|
||||
let get_resp = client.get(get_url).send().await.expect("GET failed");
|
||||
assert_eq!(get_resp.status(), 200);
|
||||
let returned = get_resp.bytes().await.unwrap();
|
||||
assert_eq!(
|
||||
returned.as_ref(),
|
||||
bytes.as_slice(),
|
||||
"GET must return original bytes"
|
||||
);
|
||||
|
||||
println!("✅ Real image upload round-trip passed");
|
||||
}
|
||||
660
crates/sprout-test-client/tests/e2e_media_extended.rs
Normal file
660
crates/sprout-test-client/tests/e2e_media_extended.rs
Normal file
@@ -0,0 +1,660 @@
|
||||
//! Extended media upload integration tests — auth edge cases, content validation,
|
||||
//! multi-format uploads, WebSocket imeta validation.
|
||||
//!
|
||||
//! Run: cargo test -p sprout-test-client --test e2e_media_extended -- --ignored --nocapture
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
||||
use reqwest::Client;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::Duration;
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn relay_ws_url() -> String {
|
||||
relay_http_url()
|
||||
.replace("http://", "ws://")
|
||||
.replace("https://", "wss://")
|
||||
}
|
||||
|
||||
fn http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.expect("http client")
|
||||
}
|
||||
|
||||
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_u64();
|
||||
let tags = vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
];
|
||||
EventBuilder::new(Kind::from(24242), "Upload test", tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn blossom_auth_header(event: &nostr::Event) -> String {
|
||||
format!(
|
||||
"Nostr {}",
|
||||
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
async fn upload(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Response {
|
||||
let sha256 = hex::encode(Sha256::digest(body));
|
||||
let auth = sign_blossom_auth(keys, &sha256);
|
||||
client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("X-SHA-256", &sha256)
|
||||
.body(body.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload request")
|
||||
}
|
||||
|
||||
// ── Minimal test images ─────────────────────────────────────────────────────
|
||||
|
||||
fn tiny_jpeg() -> Vec<u8> {
|
||||
vec![
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
|
||||
0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
|
||||
0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
|
||||
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
|
||||
0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
|
||||
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
|
||||
0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
|
||||
0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05,
|
||||
0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
|
||||
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
|
||||
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A,
|
||||
0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
|
||||
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
|
||||
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
|
||||
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93,
|
||||
0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9,
|
||||
0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
|
||||
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
|
||||
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7,
|
||||
0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
|
||||
0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xFF, 0xD9,
|
||||
]
|
||||
}
|
||||
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
// Valid 2x2 red PNG generated by ffmpeg
|
||||
vec![
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
||||
0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd,
|
||||
0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x00, 0x01,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x4f, 0x25, 0xc4, 0xd6, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44,
|
||||
0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d,
|
||||
0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44,
|
||||
0xae, 0x42, 0x60, 0x82,
|
||||
]
|
||||
}
|
||||
|
||||
fn tiny_gif() -> Vec<u8> {
|
||||
vec![
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a
|
||||
0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, // LSD 1x1
|
||||
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // GCT
|
||||
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Descriptor
|
||||
0x02, 0x02, 0x4C, 0x01, 0x00, // Image Data
|
||||
0x3B, // Trailer
|
||||
]
|
||||
}
|
||||
|
||||
fn tiny_webp() -> Vec<u8> {
|
||||
// Valid 2x2 red lossy WebP generated by ffmpeg
|
||||
vec![
|
||||
0x52, 0x49, 0x46, 0x46, 0x3c, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38,
|
||||
0x20, 0x30, 0x00, 0x00, 0x00, 0xd0, 0x01, 0x00, 0x9d, 0x01, 0x2a, 0x02, 0x00, 0x02, 0x00,
|
||||
0x02, 0x00, 0x34, 0x25, 0xa0, 0x02, 0x74, 0xba, 0x01, 0xf8, 0x00, 0x03, 0xb0, 0x00, 0xfe,
|
||||
0xf0, 0xe8, 0xf7, 0xff, 0x20, 0xb9, 0x61, 0x75, 0xc8, 0xd7, 0xff, 0x20, 0x3f, 0xe4, 0x07,
|
||||
0xfc, 0x80, 0xff, 0xf8, 0xf2, 0x00, 0x00, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
// ── Auth edge case helpers ──────────────────────────────────────────────────
|
||||
|
||||
fn sign_custom_auth(keys: &Keys, kind: u16, content: &str, tags: Vec<Tag>) -> nostr::Event {
|
||||
EventBuilder::new(Kind::from(kind), content, tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn upload_with_auth(
|
||||
client: &Client,
|
||||
auth_event: &nostr::Event,
|
||||
sha256: &str,
|
||||
body: &[u8],
|
||||
) -> reqwest::Response {
|
||||
client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(auth_event))
|
||||
.header("X-SHA-256", sha256)
|
||||
.body(body.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload request")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// MULTI-FORMAT UPLOAD TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_png_roundtrip() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let png = tiny_png();
|
||||
let resp = upload(&client, &keys, &png).await;
|
||||
assert_eq!(resp.status(), 200, "PNG upload should succeed");
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "image/png");
|
||||
assert!(desc["url"].as_str().unwrap().ends_with(".png"));
|
||||
println!("✅ PNG upload: {}", desc["url"]);
|
||||
|
||||
// GET back
|
||||
let get = client
|
||||
.get(desc["url"].as_str().unwrap())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(get.status(), 200);
|
||||
assert_eq!(get.bytes().await.unwrap().as_ref(), png.as_slice());
|
||||
println!("✅ PNG GET roundtrip verified");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_gif_roundtrip() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let gif = tiny_gif();
|
||||
let resp = upload(&client, &keys, &gif).await;
|
||||
assert_eq!(resp.status(), 200, "GIF upload should succeed");
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "image/gif");
|
||||
assert!(desc["url"].as_str().unwrap().ends_with(".gif"));
|
||||
println!("✅ GIF upload: {}", desc["url"]);
|
||||
|
||||
let get = client
|
||||
.get(desc["url"].as_str().unwrap())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(get.status(), 200);
|
||||
assert_eq!(get.bytes().await.unwrap().as_ref(), gif.as_slice());
|
||||
println!("✅ GIF GET roundtrip verified");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_webp_roundtrip() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let webp = tiny_webp();
|
||||
let resp = upload(&client, &keys, &webp).await;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
println!("WebP upload → {status}: {body}");
|
||||
assert_eq!(status, 200, "WebP upload should succeed");
|
||||
let desc: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "image/webp");
|
||||
assert!(desc["url"].as_str().unwrap().ends_with(".webp"));
|
||||
println!("✅ WebP upload: {}", desc["url"]);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// AUTH EDGE CASE TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_wrong_kind() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_u64();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
27235,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "wrong kind must be 401");
|
||||
println!("✅ Wrong kind → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_missing_t_tag() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_u64();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "missing t tag must be 401");
|
||||
println!("✅ Missing t tag → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_missing_expiration() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "missing expiration must be 401");
|
||||
println!("✅ Missing expiration → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_expired_token() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_u64();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &(now - 60).to_string()]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "expired token must be 401");
|
||||
println!("✅ Expired token → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_empty_content() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_u64();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"",
|
||||
vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "empty content must be 401");
|
||||
println!("✅ Empty content → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_server_tag_mismatch() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_u64();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
Tag::parse(&["server", "evil.example.com"]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "server tag mismatch must be 401");
|
||||
println!("✅ Server tag mismatch → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_server_tag_correct() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_u64();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(&["t", "upload"]).unwrap(),
|
||||
Tag::parse(&["x", &sha256]).unwrap(),
|
||||
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
Tag::parse(&["server", "localhost:3000"]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 200, "correct server tag must be 200");
|
||||
println!("✅ Correct server tag → 200");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CONTENT VALIDATION TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reject_svg() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let svg = b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
|
||||
let resp = upload(&client, &keys, svg).await;
|
||||
let status = resp.status().as_u16();
|
||||
assert!(
|
||||
status == 400 || status == 415,
|
||||
"SVG must be 400 or 415, got {status}"
|
||||
);
|
||||
println!("✅ SVG → {status}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reject_pdf() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let pdf = b"%PDF-1.4 fake pdf content here for testing";
|
||||
let resp = upload(&client, &keys, pdf).await;
|
||||
// PDF might be 400 (unknown) or 415 (disallowed) depending on infer detection
|
||||
let status = resp.status().as_u16();
|
||||
assert!(
|
||||
status == 400 || status == 415,
|
||||
"PDF must be 400 or 415, got {status}"
|
||||
);
|
||||
println!("✅ PDF → {status}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reject_zero_bytes() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let resp = upload(&client, &keys, b"").await;
|
||||
assert_eq!(resp.status(), 400, "zero bytes must be 400");
|
||||
println!("✅ Zero bytes → 400");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reject_random_bytes() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let random: Vec<u8> = (0..1000).map(|i| (i * 37 % 256) as u8).collect();
|
||||
let resp = upload(&client, &keys, &random).await;
|
||||
let status = resp.status().as_u16();
|
||||
assert!(
|
||||
status == 400 || status == 415,
|
||||
"random bytes must be rejected, got {status}"
|
||||
);
|
||||
println!("✅ Random bytes → {status}");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CONCURRENT UPLOAD TEST
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_concurrent_upload_same_file() {
|
||||
let client = http_client();
|
||||
let jpeg = tiny_jpeg();
|
||||
let keys1 = Keys::generate();
|
||||
let keys2 = Keys::generate();
|
||||
|
||||
let c1 = client.clone();
|
||||
let c2 = client.clone();
|
||||
let j1 = jpeg.clone();
|
||||
let j2 = jpeg.clone();
|
||||
|
||||
let (r1, r2) = tokio::join!(async { upload(&c1, &keys1, &j1).await }, async {
|
||||
upload(&c2, &keys2, &j2).await
|
||||
},);
|
||||
|
||||
assert_eq!(r1.status(), 200, "concurrent upload 1 must succeed");
|
||||
assert_eq!(r2.status(), 200, "concurrent upload 2 must succeed");
|
||||
|
||||
let d1: serde_json::Value = r1.json().await.unwrap();
|
||||
let d2: serde_json::Value = r2.json().await.unwrap();
|
||||
assert_eq!(d1["sha256"], d2["sha256"], "same content = same hash");
|
||||
assert_eq!(d1["url"], d2["url"], "same content = same URL");
|
||||
println!("✅ Concurrent upload: both succeeded, same sha256/url");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// WEBSOCKET IMETA VALIDATION
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_ws_valid_imeta() {
|
||||
use sprout_test_client::SproutTestClient;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
let http = http_client();
|
||||
|
||||
// Create channel via REST
|
||||
let ch: serde_json::Value = http
|
||||
.post(format!("{}/api/channels", relay_http_url()))
|
||||
.header("X-Pubkey", &pubkey_hex)
|
||||
.json(&serde_json::json!({
|
||||
"name": format!("ws-imeta-test-{}", rand::random::<u32>()),
|
||||
"channel_type": "stream",
|
||||
"visibility": "open"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_id = ch["id"].as_str().unwrap();
|
||||
println!("Channel: {channel_id}");
|
||||
|
||||
// Upload a JPEG to get a valid sha256
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let resp = upload(&http, &keys, &jpeg).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Connect via WebSocket
|
||||
let mut client = SproutTestClient::connect(&relay_ws_url(), &keys)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send event with valid imeta
|
||||
let event = EventBuilder::new(
|
||||
Kind::from(9),
|
||||
"image via ws",
|
||||
vec![
|
||||
Tag::parse(&["h", channel_id]).unwrap(),
|
||||
Tag::parse(&[
|
||||
"imeta",
|
||||
&format!("url http://localhost:3000/media/{sha256}.jpg"),
|
||||
"m image/jpeg",
|
||||
&format!("x {sha256}"),
|
||||
"size 347",
|
||||
])
|
||||
.unwrap(),
|
||||
],
|
||||
)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = client.send_event(event).await.unwrap();
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"valid imeta via WS must be accepted: {:?}",
|
||||
ok.message
|
||||
);
|
||||
println!("✅ WS valid imeta accepted");
|
||||
|
||||
client.disconnect().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_ws_invalid_imeta_external_url() {
|
||||
use sprout_test_client::SproutTestClient;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
let http = http_client();
|
||||
|
||||
let ch: serde_json::Value = http
|
||||
.post(format!("{}/api/channels", relay_http_url()))
|
||||
.header("X-Pubkey", &pubkey_hex)
|
||||
.json(&serde_json::json!({
|
||||
"name": format!("ws-imeta-bad-{}", rand::random::<u32>()),
|
||||
"channel_type": "stream",
|
||||
"visibility": "open"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_id = ch["id"].as_str().unwrap();
|
||||
|
||||
let sha = "a".repeat(64);
|
||||
let mut client = SproutTestClient::connect(&relay_ws_url(), &keys)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = EventBuilder::new(
|
||||
Kind::from(9),
|
||||
"bad imeta",
|
||||
vec![
|
||||
Tag::parse(&["h", channel_id]).unwrap(),
|
||||
Tag::parse(&[
|
||||
"imeta",
|
||||
&format!("url https://evil.com/media/{sha}.jpg"),
|
||||
"m image/jpeg",
|
||||
&format!("x {sha}"),
|
||||
"size 347",
|
||||
])
|
||||
.unwrap(),
|
||||
],
|
||||
)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = client.send_event(event).await.unwrap();
|
||||
assert!(!ok.accepted, "external URL imeta via WS must be rejected");
|
||||
assert!(
|
||||
ok.message.contains("invalid"),
|
||||
"rejection message must contain 'invalid': {:?}",
|
||||
ok.message
|
||||
);
|
||||
println!(
|
||||
"✅ WS invalid imeta (external URL) rejected: {:?}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
client.disconnect().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_ws_invalid_imeta_missing_fields() {
|
||||
use sprout_test_client::SproutTestClient;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
let http = http_client();
|
||||
|
||||
let ch: serde_json::Value = http
|
||||
.post(format!("{}/api/channels", relay_http_url()))
|
||||
.header("X-Pubkey", &pubkey_hex)
|
||||
.json(&serde_json::json!({
|
||||
"name": format!("ws-imeta-miss-{}", rand::random::<u32>()),
|
||||
"channel_type": "stream",
|
||||
"visibility": "open"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_id = ch["id"].as_str().unwrap();
|
||||
|
||||
let sha = "b".repeat(64);
|
||||
let mut client = SproutTestClient::connect(&relay_ws_url(), &keys)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Only url, missing m/x/size
|
||||
let event = EventBuilder::new(
|
||||
Kind::from(9),
|
||||
"incomplete imeta",
|
||||
vec![
|
||||
Tag::parse(&["h", channel_id]).unwrap(),
|
||||
Tag::parse(&[
|
||||
"imeta",
|
||||
&format!("url http://localhost:3000/media/{sha}.jpg"),
|
||||
])
|
||||
.unwrap(),
|
||||
],
|
||||
)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = client.send_event(event).await.unwrap();
|
||||
assert!(!ok.accepted, "incomplete imeta via WS must be rejected");
|
||||
println!("✅ WS incomplete imeta rejected: {:?}", ok.message);
|
||||
|
||||
client.disconnect().await.unwrap();
|
||||
}
|
||||
@@ -24,6 +24,7 @@ allow = [
|
||||
"CC0-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"MITNFA",
|
||||
"MPL-2.0",
|
||||
]
|
||||
confidence-threshold = 0.8
|
||||
|
||||
|
||||
@@ -32,10 +32,11 @@ const rules = [
|
||||
const overrides = new Map([
|
||||
["src/app/AppShell.tsx", 700],
|
||||
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
||||
["src/features/messages/ui/MessageComposer.tsx", 650], // media upload handlers (paste, drop, dialog)
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 650],
|
||||
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
|
||||
["src/shared/api/tauri.ts", 900],
|
||||
["src/shared/api/tauri.ts", 950],
|
||||
]);
|
||||
|
||||
async function walkFiles(directory) {
|
||||
|
||||
82
desktop/src-tauri/Cargo.lock
generated
82
desktop/src-tauri/Cargo.lock
generated
@@ -1969,6 +1969,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.19.0"
|
||||
@@ -3459,6 +3468,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -4019,6 +4052,9 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"hex",
|
||||
"infer 0.16.0",
|
||||
"libc",
|
||||
"nostr 0.37.0",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -4027,9 +4063,13 @@ dependencies = [
|
||||
"sprout-core",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-websocket",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4361,6 +4401,46 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.3"
|
||||
@@ -4483,7 +4563,7 @@ dependencies = [
|
||||
"glob",
|
||||
"html5ever",
|
||||
"http",
|
||||
"infer",
|
||||
"infer 0.19.0",
|
||||
"json-patch",
|
||||
"kuchikiki",
|
||||
"log",
|
||||
|
||||
@@ -17,15 +17,26 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
infer = "0.16"
|
||||
hex = "0.4"
|
||||
tokio = { version = "1", features = ["fs", "sync"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
nostr = "0.37"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
url = "2"
|
||||
sprout-core = { path = "../../crates/sprout-core" }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"core:window:allow-close",
|
||||
"opener:default",
|
||||
"websocket:default",
|
||||
"window-state:default"
|
||||
"window-state:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
||||
336
desktop/src-tauri/src/commands/media.rs
Normal file
336
desktop/src-tauri/src/commands/media.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::relay::relay_api_base_url;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlobDescriptor {
|
||||
pub url: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
#[serde(rename = "type")]
|
||||
pub mime_type: String,
|
||||
pub uploaded: i64,
|
||||
pub dim: Option<String>,
|
||||
pub blurhash: Option<String>,
|
||||
pub thumb: Option<String>,
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the server authority from a URL for BUD-11 server tag scoping.
|
||||
///
|
||||
/// Returns `host` for default ports (80/443), `host:port` for non-default ports.
|
||||
fn extract_server_authority(url_str: &str) -> Option<String> {
|
||||
let parsed = url::Url::parse(url_str).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
match parsed.port() {
|
||||
Some(port) => Some(format!("{host}:{port}")),
|
||||
None => Some(host.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the real filesystem path of an already-opened file descriptor.
|
||||
///
|
||||
/// Returns the path the kernel associates with the inode, not the pathname
|
||||
/// used to open it. Immune to post-open renames/symlink swaps.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn fd_real_path(file: &std::fs::File) -> Result<std::path::PathBuf, String> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = file.as_raw_fd();
|
||||
let mut buf = vec![0u8; libc::PATH_MAX as usize];
|
||||
let ret = unsafe { libc::fcntl(fd, libc::F_GETPATH, buf.as_mut_ptr()) };
|
||||
if ret == -1 {
|
||||
return Err(format!(
|
||||
"fcntl F_GETPATH failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
let s = std::str::from_utf8(&buf[..nul]).map_err(|e| e.to_string())?;
|
||||
Ok(std::path::PathBuf::from(s))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn fd_real_path(file: &std::fs::File) -> Result<std::path::PathBuf, String> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = file.as_raw_fd();
|
||||
std::fs::read_link(format!("/proc/self/fd/{fd}")).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn fd_real_path(file: &std::fs::File) -> Result<std::path::PathBuf, String> {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
GetFinalPathNameByHandleW, FILE_NAME_NORMALIZED,
|
||||
};
|
||||
let handle = file.as_raw_handle() as isize;
|
||||
let mut buf = vec![0u16; 1024];
|
||||
let len = unsafe {
|
||||
GetFinalPathNameByHandleW(handle, buf.as_mut_ptr(), buf.len() as u32, FILE_NAME_NORMALIZED)
|
||||
};
|
||||
if len == 0 {
|
||||
return Err(format!(
|
||||
"GetFinalPathNameByHandleW failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
let path_str = String::from_utf16_lossy(&buf[..len as usize]);
|
||||
// Strip \\?\ prefix that Windows adds
|
||||
let cleaned = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
|
||||
Ok(std::path::PathBuf::from(cleaned))
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn fd_real_path(_file: &std::fs::File) -> Result<std::path::PathBuf, String> {
|
||||
Err("fd_real_path not supported on this platform".to_string())
|
||||
}
|
||||
|
||||
/// MIME allowlist — must match the server's `ALLOWED_MIME_TYPES`.
|
||||
const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||
|
||||
fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
|
||||
let mime = infer::get(body)
|
||||
.map(|t| t.mime_type().to_string())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
if !ALLOWED_MIME.contains(&mime.as_str()) {
|
||||
return Err(format!("unsupported file type: {mime}"));
|
||||
}
|
||||
Ok(mime)
|
||||
}
|
||||
|
||||
fn sign_blossom_upload_auth(keys: &Keys, sha256: &str) -> Result<nostr::Event, String> {
|
||||
let now = Timestamp::now().as_u64();
|
||||
let mut tags = vec![
|
||||
Tag::parse(vec!["t", "upload"]).map_err(|e| e.to_string())?,
|
||||
Tag::parse(vec!["x", sha256]).map_err(|e| e.to_string())?,
|
||||
Tag::parse(vec!["expiration", &(now + 300).to_string()]).map_err(|e| e.to_string())?,
|
||||
];
|
||||
let base_url = relay_api_base_url();
|
||||
if let Some(domain) = extract_server_authority(&base_url) {
|
||||
tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?);
|
||||
}
|
||||
EventBuilder::new(Kind::from(24242), "Upload sprout-media")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Execute the upload HTTP request. Shared by all upload entry points.
|
||||
async fn do_upload(
|
||||
body: Vec<u8>,
|
||||
mime: &str,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<BlobDescriptor, String> {
|
||||
let sha256 = format!("{:x}", Sha256::digest(&body));
|
||||
|
||||
let auth_event = {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
sign_blossom_upload_auth(&keys, &sha256)?
|
||||
};
|
||||
|
||||
let auth_header = format!(
|
||||
"Nostr {}",
|
||||
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
|
||||
);
|
||||
|
||||
let base_url = relay_api_base_url();
|
||||
let mut req = state
|
||||
.http_client
|
||||
.put(format!("{base_url}/media/upload"))
|
||||
.header("Authorization", &auth_header)
|
||||
.header("Content-Type", mime)
|
||||
.header("X-SHA-256", &sha256);
|
||||
|
||||
if let Some(ref token) = state.configured_api_token {
|
||||
req = req.header("X-Auth-Token", token.as_str());
|
||||
} else if let Ok(guard) = state.session_token.lock() {
|
||||
if let Some(ref token) = *guard {
|
||||
req = req.header("X-Auth-Token", token.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("upload failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("upload failed ({status}): {text}"));
|
||||
}
|
||||
|
||||
resp.json::<BlobDescriptor>()
|
||||
.await
|
||||
.map_err(|e| format!("parse failed: {e}"))
|
||||
}
|
||||
|
||||
// ── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Upload a file that is already in the OS temp directory.
|
||||
///
|
||||
/// Trust boundary: only reads files inside `temp_dir()`. Opens the fd first,
|
||||
/// then resolves the fd's real path to verify containment (TOCTOU-safe).
|
||||
#[tauri::command]
|
||||
pub async fn upload_media(
|
||||
file_path: String,
|
||||
is_temp: bool,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BlobDescriptor, String> {
|
||||
let path = std::path::Path::new(&file_path);
|
||||
let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
|
||||
let fd_path = fd_real_path(&file)?;
|
||||
let canonical_temp = std::env::temp_dir()
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::env::temp_dir());
|
||||
if !fd_path.starts_with(&canonical_temp) {
|
||||
return Err("upload source must be in system temp directory".to_string());
|
||||
}
|
||||
|
||||
use std::io::Read;
|
||||
let mut body = Vec::new();
|
||||
file.read_to_end(&mut body)
|
||||
.map_err(|e| format!("failed to read file: {e}"))?;
|
||||
drop(file);
|
||||
|
||||
if is_temp {
|
||||
let _ = std::fs::remove_file(&fd_path);
|
||||
}
|
||||
|
||||
let mime = detect_and_validate_mime(&body)?;
|
||||
do_upload(body, &mime, &state).await
|
||||
}
|
||||
|
||||
/// Open a native file dialog, read the selected file, and upload it.
|
||||
///
|
||||
/// All file I/O happens in trusted Rust — the renderer never touches the
|
||||
/// filesystem. This is the secure path for the 📎 paperclip button.
|
||||
///
|
||||
/// **Residual TOCTOU note:** The Tauri dialog plugin returns a pathname, not
|
||||
/// a file handle, so there is a small race window between dialog return and
|
||||
/// `File::open()`. This is an inherent limitation of the OS file-picker API
|
||||
/// (no platform exposes a handle/bookmark from the open-file dialog in a way
|
||||
/// the Tauri plugin surfaces). The risk is bounded: the attacker must be local
|
||||
/// and must win a race against an immediate open. Server-side content validation
|
||||
/// (MIME, image decode, size caps) provides defense-in-depth.
|
||||
#[tauri::command]
|
||||
pub async fn pick_and_upload_media(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<BlobDescriptor>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.dialog()
|
||||
.file()
|
||||
.add_filter("Images", &["jpg", "jpeg", "png", "gif", "webp"])
|
||||
.pick_file(move |path| {
|
||||
let _ = tx.send(path);
|
||||
});
|
||||
|
||||
let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?;
|
||||
let file_path = match selected {
|
||||
Some(p) => p,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// Use the same fd-first pattern as upload_media: open the file to pin
|
||||
// the inode, then read from the handle. Prevents a local attacker from
|
||||
// swapping the selected path between dialog return and read.
|
||||
let path = file_path.as_path().ok_or("invalid path")?;
|
||||
let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
|
||||
use std::io::Read;
|
||||
let mut body = Vec::new();
|
||||
file.read_to_end(&mut body)
|
||||
.map_err(|e| format!("failed to read file: {e}"))?;
|
||||
drop(file);
|
||||
|
||||
let mime = detect_and_validate_mime(&body)?;
|
||||
do_upload(body, &mime, &state).await.map(Some)
|
||||
}
|
||||
|
||||
/// Upload raw bytes directly (for paste and drag-drop).
|
||||
///
|
||||
/// The renderer already has the bytes in memory from the clipboard/drag event.
|
||||
/// Passing them via IPC avoids granting the renderer filesystem write access.
|
||||
#[tauri::command]
|
||||
pub async fn upload_media_bytes(
|
||||
data: Vec<u8>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BlobDescriptor, String> {
|
||||
if data.is_empty() {
|
||||
return Err("empty upload".to_string());
|
||||
}
|
||||
let mime = detect_and_validate_mime(&data)?;
|
||||
do_upload(data, &mime, &state).await
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_server_authority_default_ports() {
|
||||
assert_eq!(
|
||||
extract_server_authority("https://relay.example.com"),
|
||||
Some("relay.example.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_server_authority("https://relay.example.com:443"),
|
||||
Some("relay.example.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_server_authority("http://relay.example.com:80"),
|
||||
Some("relay.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_server_authority_non_default_ports() {
|
||||
assert_eq!(
|
||||
extract_server_authority("http://localhost:3000"),
|
||||
Some("localhost:3000".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_server_authority("https://relay.example.com:8443"),
|
||||
Some("relay.example.com:8443".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_server_authority_ipv6() {
|
||||
assert_eq!(
|
||||
extract_server_authority("http://[::1]:3000"),
|
||||
Some("[::1]:3000".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_server_authority_invalid() {
|
||||
assert_eq!(extract_server_authority("not-a-url"), None);
|
||||
assert_eq!(extract_server_authority(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_and_validate_mime_jpeg() {
|
||||
// Minimal JPEG: SOI + EOI
|
||||
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0];
|
||||
assert_eq!(detect_and_validate_mime(&jpeg).unwrap(), "image/jpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_and_validate_mime_rejects_text() {
|
||||
let text = b"hello world";
|
||||
assert!(detect_and_validate_mime(text).is_err());
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ pub async fn send_channel_message(
|
||||
channel_id: String,
|
||||
content: String,
|
||||
parent_event_id: Option<String>,
|
||||
media_tags: Option<Vec<Vec<String>>>,
|
||||
mention_pubkeys: Option<Vec<String>>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SendChannelMessageResponse, String> {
|
||||
let path = format!("/api/channels/{channel_id}/messages");
|
||||
@@ -53,6 +55,8 @@ pub async fn send_channel_message(
|
||||
content: content.trim(),
|
||||
parent_event_id: parent_event_id.as_deref(),
|
||||
broadcast_to_channel: false,
|
||||
media_tags,
|
||||
mention_pubkeys: mention_pubkeys.unwrap_or_default(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod agents;
|
||||
mod channels;
|
||||
mod identity;
|
||||
mod media;
|
||||
mod messages;
|
||||
mod profile;
|
||||
mod tokens;
|
||||
@@ -8,6 +9,7 @@ mod tokens;
|
||||
pub use agents::*;
|
||||
pub use channels::*;
|
||||
pub use identity::*;
|
||||
pub use media::*;
|
||||
pub use messages::*;
|
||||
pub use profile::*;
|
||||
pub use tokens::*;
|
||||
|
||||
@@ -19,6 +19,7 @@ pub fn run() {
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(build_app_state())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_identity,
|
||||
@@ -53,6 +54,9 @@ pub fn run() {
|
||||
add_reaction,
|
||||
remove_reaction,
|
||||
get_event,
|
||||
upload_media,
|
||||
pick_and_upload_media,
|
||||
upload_media_bytes,
|
||||
list_tokens,
|
||||
mint_token,
|
||||
revoke_token,
|
||||
|
||||
@@ -174,6 +174,10 @@ pub struct SendChannelMessageBody<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_event_id: Option<&'a str>,
|
||||
pub broadcast_to_channel: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media_tags: Option<Vec<Vec<String>>>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub mention_pubkeys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -620,11 +620,12 @@ export function AppShell() {
|
||||
onCancelReply={() => {
|
||||
setReplyTargetId(null);
|
||||
}}
|
||||
onSend={async (content, mentionPubkeys) => {
|
||||
onSend={async (content, mentionPubkeys, mediaTags) => {
|
||||
await sendMessageMutation.mutateAsync({
|
||||
content,
|
||||
mentionPubkeys,
|
||||
parentEventId: replyTargetId,
|
||||
mediaTags,
|
||||
});
|
||||
setReplyTargetId(null);
|
||||
}}
|
||||
|
||||
@@ -62,6 +62,7 @@ function createOptimisticMessage(
|
||||
currentMessages: RelayEvent[],
|
||||
mentionPubkeys: string[] = [],
|
||||
parentEventId: string | null = null,
|
||||
mediaTags: string[][] = [],
|
||||
): RelayEvent {
|
||||
const tags: string[][] = [];
|
||||
|
||||
@@ -81,6 +82,10 @@ function createOptimisticMessage(
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of mediaTags) {
|
||||
tags.push(tag);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `optimistic-${crypto.randomUUID()}`,
|
||||
pubkey: identity.pubkey,
|
||||
@@ -218,10 +223,16 @@ export function useSendMessageMutation(
|
||||
content: string;
|
||||
mentionPubkeys?: string[];
|
||||
parentEventId?: string | null;
|
||||
mediaTags?: string[][];
|
||||
},
|
||||
MessageQueryContext | undefined
|
||||
>({
|
||||
mutationFn: async ({ content, mentionPubkeys, parentEventId }) => {
|
||||
mutationFn: async ({
|
||||
content,
|
||||
mentionPubkeys,
|
||||
parentEventId,
|
||||
mediaTags,
|
||||
}) => {
|
||||
if (!channel || channel.channelType === "forum") {
|
||||
throw new Error("This channel does not support message sending yet.");
|
||||
}
|
||||
@@ -230,7 +241,9 @@ export function useSendMessageMutation(
|
||||
throw new Error("No identity available for sending messages.");
|
||||
}
|
||||
|
||||
if (parentEventId) {
|
||||
// Media-bearing messages MUST go through REST so the relay's imeta
|
||||
// validation runs. The WebSocket path does not validate imeta tags.
|
||||
if (parentEventId || (mediaTags && mediaTags.length > 0)) {
|
||||
const cachedMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>([
|
||||
"channel-messages",
|
||||
@@ -239,28 +252,52 @@ export function useSendMessageMutation(
|
||||
const result = await sendChannelMessage(
|
||||
channel.id,
|
||||
content,
|
||||
parentEventId,
|
||||
parentEventId ?? null,
|
||||
mediaTags,
|
||||
mentionPubkeys,
|
||||
);
|
||||
|
||||
// Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta.
|
||||
// For replies, buildReplyTags already includes ["p", author] and ["h", channel].
|
||||
// For non-replies (media-only), we add them ourselves.
|
||||
const replyTags = parentEventId
|
||||
? buildReplyTags(
|
||||
channel.id,
|
||||
identity.pubkey,
|
||||
parentEventId,
|
||||
resolveReplyRootId(parentEventId, cachedMessages),
|
||||
)
|
||||
: [];
|
||||
const baseTags = parentEventId
|
||||
? replyTags // buildReplyTags includes h + author p
|
||||
: [
|
||||
["h", channel.id],
|
||||
["p", identity.pubkey],
|
||||
]; // non-reply: add ourselves
|
||||
|
||||
return {
|
||||
id: result.eventId,
|
||||
pubkey: identity.pubkey,
|
||||
created_at: result.createdAt,
|
||||
kind: KIND_STREAM_MESSAGE,
|
||||
tags: buildReplyTags(
|
||||
channel.id,
|
||||
identity.pubkey,
|
||||
parentEventId,
|
||||
resolveReplyRootId(parentEventId, cachedMessages),
|
||||
),
|
||||
tags: [
|
||||
...baseTags,
|
||||
...(mentionPubkeys ?? []).map((pk) => ["p", pk]),
|
||||
...(mediaTags ?? []),
|
||||
],
|
||||
content: content.trim(),
|
||||
sig: "",
|
||||
};
|
||||
}
|
||||
|
||||
return relayClient.sendMessage(channel.id, content, mentionPubkeys ?? []);
|
||||
return relayClient.sendMessage(
|
||||
channel.id,
|
||||
content,
|
||||
mentionPubkeys ?? [],
|
||||
[],
|
||||
);
|
||||
},
|
||||
onMutate: async ({ content, mentionPubkeys, parentEventId }) => {
|
||||
onMutate: async ({ content, mentionPubkeys, parentEventId, mediaTags }) => {
|
||||
if (!channel || !identity || channel.channelType === "forum") {
|
||||
return undefined;
|
||||
}
|
||||
@@ -277,6 +314,7 @@ export function useSendMessageMutation(
|
||||
previousMessages,
|
||||
mentionPubkeys ?? [],
|
||||
parentEventId ?? null,
|
||||
mediaTags ?? [],
|
||||
);
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
|
||||
52
desktop/src/features/messages/lib/parseImeta.ts
Normal file
52
desktop/src/features/messages/lib/parseImeta.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type ImetaEntry = {
|
||||
url: string;
|
||||
m: string;
|
||||
x: string;
|
||||
size: number;
|
||||
dim?: string;
|
||||
blurhash?: string;
|
||||
alt?: string;
|
||||
thumb?: string;
|
||||
};
|
||||
|
||||
export function parseImetaTags(tags: string[][]): Map<string, ImetaEntry> {
|
||||
const map = new Map<string, ImetaEntry>();
|
||||
for (const tag of tags) {
|
||||
if (tag[0] !== "imeta") continue;
|
||||
const entry: Partial<ImetaEntry> = {};
|
||||
for (const part of tag.slice(1)) {
|
||||
const spaceIdx = part.indexOf(" ");
|
||||
if (spaceIdx === -1) continue;
|
||||
const key = part.slice(0, spaceIdx);
|
||||
const val = part.slice(spaceIdx + 1);
|
||||
switch (key) {
|
||||
case "url":
|
||||
entry.url = val;
|
||||
break;
|
||||
case "m":
|
||||
entry.m = val;
|
||||
break;
|
||||
case "x":
|
||||
entry.x = val;
|
||||
break;
|
||||
case "size":
|
||||
entry.size = parseInt(val, 10);
|
||||
break;
|
||||
case "dim":
|
||||
entry.dim = val;
|
||||
break;
|
||||
case "blurhash":
|
||||
entry.blurhash = val;
|
||||
break;
|
||||
case "alt":
|
||||
entry.alt = val;
|
||||
break;
|
||||
case "thumb":
|
||||
entry.thumb = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (entry.url) map.set(entry.url, entry as ImetaEntry);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import * as React from "react";
|
||||
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import { useChannelMembersQuery } from "@/features/channels/hooks";
|
||||
import {
|
||||
type BlobDescriptor,
|
||||
pickAndUploadMedia,
|
||||
uploadMediaBytes,
|
||||
} from "@/shared/api/tauri";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import {
|
||||
@@ -16,7 +21,11 @@ type MessageComposerProps = {
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
onCancelReply?: () => void;
|
||||
onSend: (content: string, mentionPubkeys: string[]) => Promise<void>;
|
||||
onSend: (
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => Promise<void>;
|
||||
placeholder?: string;
|
||||
replyTarget?: {
|
||||
author: string;
|
||||
@@ -67,6 +76,13 @@ export function MessageComposer({
|
||||
const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0);
|
||||
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Upload state
|
||||
const [uploadState, setUploadState] = React.useState<{
|
||||
status: "idle" | "uploading" | "error";
|
||||
message?: string;
|
||||
}>({ status: "idle" });
|
||||
const [pendingImeta, setPendingImeta] = React.useState<BlobDescriptor[]>([]);
|
||||
|
||||
const membersQuery = useChannelMembersQuery(channelId);
|
||||
const members = membersQuery.data ?? [];
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
@@ -179,23 +195,149 @@ export function MessageComposer({
|
||||
[members, managedAgentNamesByPubkey],
|
||||
);
|
||||
|
||||
// Shared handler: got a descriptor back from any upload path.
|
||||
const onUploaded = React.useCallback((descriptor: BlobDescriptor) => {
|
||||
const markdown = `\n\n`;
|
||||
setContent((prev) => prev + markdown);
|
||||
setPendingImeta((prev) => [...prev, descriptor]);
|
||||
setUploadState({ status: "idle" });
|
||||
}, []);
|
||||
|
||||
// 📎 Paperclip: native file dialog + read + upload, all in trusted Rust.
|
||||
// The renderer never touches the filesystem.
|
||||
const handlePaperclip = React.useCallback(async () => {
|
||||
setUploadState({ status: "uploading" });
|
||||
try {
|
||||
const descriptor = await pickAndUploadMedia();
|
||||
if (descriptor) {
|
||||
onUploaded(descriptor);
|
||||
} else {
|
||||
setUploadState({ status: "idle" }); // user cancelled dialog
|
||||
}
|
||||
} catch (err) {
|
||||
setUploadState({ status: "error", message: String(err) });
|
||||
}
|
||||
}, [onUploaded]);
|
||||
|
||||
// 🖱️ Drop: read bytes in JS, send via IPC to Rust for upload.
|
||||
// No filesystem access needed — bytes come from the drag event.
|
||||
const handleDrop = React.useCallback(
|
||||
async (event: React.DragEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
setUploadState({
|
||||
status: "error",
|
||||
message: "Only JPEG, PNG, GIF, and WebP images are supported",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadState({ status: "uploading" });
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
|
||||
onUploaded(descriptor);
|
||||
} catch (err) {
|
||||
setUploadState({ status: "error", message: String(err) });
|
||||
}
|
||||
},
|
||||
[onUploaded],
|
||||
);
|
||||
|
||||
const handleDragOver = React.useCallback(
|
||||
(event: React.DragEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 📋 Paste: read bytes from clipboard, send via IPC to Rust for upload.
|
||||
const handlePaste = React.useCallback(
|
||||
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = Array.from(event.clipboardData.items);
|
||||
const ALLOWED_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const imageItem = items.find((item) => ALLOWED_TYPES.includes(item.type));
|
||||
if (!imageItem) return;
|
||||
|
||||
event.preventDefault();
|
||||
const file = imageItem.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
setUploadState({ status: "uploading" });
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
|
||||
onUploaded(descriptor);
|
||||
} catch (err) {
|
||||
setUploadState({ status: "error", message: String(err) });
|
||||
}
|
||||
},
|
||||
[onUploaded],
|
||||
);
|
||||
|
||||
const submitMessage = React.useCallback(async () => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || disabled || isSending) {
|
||||
const hasMedia = pendingImeta.length > 0;
|
||||
if ((!trimmed && !hasMedia) || disabled || isSending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pubkeys = extractMentionPubkeys(trimmed);
|
||||
|
||||
// Build imeta tags from pending descriptors
|
||||
const mediaTags =
|
||||
pendingImeta.length > 0
|
||||
? pendingImeta.map((d) => [
|
||||
"imeta",
|
||||
`url ${d.url}`,
|
||||
`m ${d.type}`,
|
||||
`x ${d.sha256}`,
|
||||
`size ${d.size}`,
|
||||
...(d.dim ? [`dim ${d.dim}`] : []),
|
||||
...(d.blurhash ? [`blurhash ${d.blurhash}`] : []),
|
||||
...(d.thumb ? [`thumb ${d.thumb}`] : []),
|
||||
])
|
||||
: undefined;
|
||||
|
||||
const savedContent = trimmed;
|
||||
const savedImeta = [...pendingImeta];
|
||||
|
||||
setContent("");
|
||||
setPendingImeta([]);
|
||||
mentionMapRef.current.clear();
|
||||
setMentionQuery(null);
|
||||
|
||||
try {
|
||||
await onSend(trimmed, pubkeys);
|
||||
await onSend(trimmed, pubkeys, mediaTags);
|
||||
} catch {
|
||||
setContent(trimmed);
|
||||
setContent(savedContent);
|
||||
setPendingImeta(savedImeta);
|
||||
}
|
||||
}, [content, disabled, isSending, onSend, extractMentionPubkeys]);
|
||||
}, [
|
||||
content,
|
||||
disabled,
|
||||
isSending,
|
||||
onSend,
|
||||
extractMentionPubkeys,
|
||||
pendingImeta,
|
||||
]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -328,12 +470,18 @@ export function MessageComposer({
|
||||
textareaRef.current?.focus();
|
||||
}, [disabled, replyTarget]);
|
||||
|
||||
const isUploading = uploadState.status === "uploading";
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border/80 bg-background p-4">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
|
||||
<form
|
||||
className="relative rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
|
||||
data-testid="message-composer"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => {
|
||||
void handleDrop(e);
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
handleSubmit(event);
|
||||
}}
|
||||
@@ -369,6 +517,19 @@ export function MessageComposer({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{uploadState.status === "error" ? (
|
||||
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Upload failed: {uploadState.message}
|
||||
<button
|
||||
className="ml-2 underline"
|
||||
onClick={() => setUploadState({ status: "idle" })}
|
||||
type="button"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Textarea
|
||||
aria-label="Message channel"
|
||||
className="min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
|
||||
@@ -376,6 +537,9 @@ export function MessageComposer({
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
void handlePaste(e);
|
||||
}}
|
||||
placeholder={
|
||||
placeholder ??
|
||||
(replyTarget
|
||||
@@ -389,8 +553,21 @@ export function MessageComposer({
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled size="icon" type="button" variant="ghost">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<Button
|
||||
disabled={disabled || isUploading}
|
||||
onClick={() => {
|
||||
void handlePaperclip();
|
||||
}}
|
||||
size="icon"
|
||||
title="Attach image"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<Paperclip className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button disabled size="icon" type="button" variant="ghost">
|
||||
<SmilePlus className="h-4 w-4" />
|
||||
@@ -400,7 +577,11 @@ export function MessageComposer({
|
||||
<Button
|
||||
className="gap-2"
|
||||
data-testid="send-message"
|
||||
disabled={disabled || isSending || content.trim().length === 0}
|
||||
disabled={
|
||||
disabled ||
|
||||
isSending ||
|
||||
(content.trim().length === 0 && pendingImeta.length === 0)
|
||||
}
|
||||
title="Send (Enter)"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -70,6 +70,7 @@ class RelayClient {
|
||||
channelId: string,
|
||||
content: string,
|
||||
mentionPubkeys: string[] = [],
|
||||
extraTags: string[][] = [],
|
||||
) {
|
||||
await this.ensureConnected();
|
||||
|
||||
@@ -77,6 +78,9 @@ class RelayClient {
|
||||
for (const pubkey of mentionPubkeys) {
|
||||
tags.push(["p", pubkey]);
|
||||
}
|
||||
for (const tag of extraTags) {
|
||||
tags.push(tag);
|
||||
}
|
||||
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_STREAM_MESSAGE,
|
||||
|
||||
@@ -591,6 +591,8 @@ export async function sendChannelMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
parentEventId?: string | null,
|
||||
mediaTags?: string[][],
|
||||
mentionPubkeys?: string[],
|
||||
): Promise<SendChannelMessageResult> {
|
||||
const response = await invokeTauri<RawSendChannelMessageResult>(
|
||||
"send_channel_message",
|
||||
@@ -598,6 +600,8 @@ export async function sendChannelMessage(
|
||||
channelId,
|
||||
content,
|
||||
parentEventId,
|
||||
mediaTags: mediaTags ?? null,
|
||||
mentionPubkeys: mentionPubkeys ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -610,6 +614,37 @@ export async function sendChannelMessage(
|
||||
};
|
||||
}
|
||||
|
||||
export type BlobDescriptor = {
|
||||
url: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
type: string;
|
||||
uploaded: number;
|
||||
dim?: string;
|
||||
blurhash?: string;
|
||||
thumb?: string;
|
||||
};
|
||||
|
||||
export async function uploadMedia(
|
||||
filePath: string,
|
||||
isTemp: boolean,
|
||||
): Promise<BlobDescriptor> {
|
||||
return invokeTauri<BlobDescriptor>("upload_media", {
|
||||
filePath,
|
||||
isTemp,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickAndUploadMedia(): Promise<BlobDescriptor | null> {
|
||||
return invokeTauri<BlobDescriptor | null>("pick_and_upload_media", {});
|
||||
}
|
||||
|
||||
export async function uploadMediaBytes(
|
||||
data: number[],
|
||||
): Promise<BlobDescriptor> {
|
||||
return invokeTauri<BlobDescriptor>("upload_media_bytes", { data });
|
||||
}
|
||||
|
||||
export async function addReaction(
|
||||
eventId: string,
|
||||
emoji: string,
|
||||
|
||||
@@ -126,6 +126,54 @@ services:
|
||||
com.sprout.env: "dev"
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: sprout-minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: sprout_dev
|
||||
MINIO_ROOT_PASSWORD: sprout_dev_secret
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
networks:
|
||||
- sprout-net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256m
|
||||
labels:
|
||||
com.sprout.service: "minio"
|
||||
com.sprout.env: "dev"
|
||||
restart: unless-stopped
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
container_name: sprout-minio-init
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- sprout-net
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 sprout_dev sprout_dev_secret &&
|
||||
mc mb --ignore-existing local/sprout-media &&
|
||||
mc anonymous set none local/sprout-media
|
||||
"
|
||||
labels:
|
||||
com.sprout.service: "minio-init"
|
||||
com.sprout.env: "dev"
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
name: sprout-mysql-data
|
||||
@@ -135,6 +183,10 @@ volumes:
|
||||
name: sprout-typesense-data
|
||||
labels:
|
||||
com.sprout.volume: "typesense"
|
||||
minio-data:
|
||||
name: sprout-minio-data
|
||||
labels:
|
||||
com.sprout.volume: "minio"
|
||||
|
||||
networks:
|
||||
sprout-net:
|
||||
|
||||
Reference in New Issue
Block a user