use prost::Message;
use sp_crypto_hashing::blake2_256;
use crate::config::ITEM_ID_NAMESPACE;
pub const LANGUAGE_MIXIN_ID: u32 = 0x9bc7_a0e6;
pub const TITLE_MIXIN_ID: u32 = 0x344f_4812;
pub const BODY_TEXT_MIXIN_ID: u32 = 0x2d38_2044;
pub const IMAGE_MIXIN_ID: u32 = 0x045e_ee8c;
pub const PROFILE_MIXIN_ID: u32 = 0xbeef_2144;
pub const FEED_TYPE_MIXIN_ID: u32 = 0xbcec_8faa;
pub const COMMENT_TYPE_MIXIN_ID: u32 = 0x874a_ba65;
pub const DEFAULT_LANGUAGE_TAG: &str = "en";
#[derive(Clone, PartialEq, Message)]
pub struct ItemMessage {
#[prost(message, repeated, tag = "1")]
pub mixin_payload: Vec<MixinPayloadMessage>,
}
#[derive(Clone, PartialEq, Message)]
pub struct MixinPayloadMessage {
#[prost(fixed32, tag = "1")]
pub mixin_id: u32,
#[prost(bytes = "vec", tag = "2")]
pub payload: Vec<u8>,
}
#[derive(Clone, PartialEq, Message)]
pub struct LanguageMixinMessage {
#[prost(string, tag = "1")]
pub language_tag: String,
}
#[derive(Clone, PartialEq, Message)]
pub struct TitleMixinMessage {
#[prost(string, tag = "1")]
pub title: String,
}
#[derive(Clone, PartialEq, Message)]
pub struct BodyTextMixinMessage {
#[prost(string, tag = "1")]
pub body_text: String,
}
#[derive(Clone, PartialEq, Message)]
pub struct ImageMixinMessage {
#[prost(string, tag = "1")]
pub filename: String,
#[prost(uint64, tag = "2")]
pub filesize: u64,
#[prost(bytes = "vec", tag = "3")]
pub ipfs_hash: Vec<u8>,
#[prost(uint32, tag = "4")]
pub width: u32,
#[prost(uint32, tag = "5")]
pub height: u32,
#[prost(message, repeated, tag = "6")]
pub mipmap_level: Vec<MipmapLevelMessage>,
}
#[derive(Clone, PartialEq, Message)]
pub struct MipmapLevelMessage {
#[prost(uint64, tag = "1")]
pub filesize: u64,
#[prost(bytes = "vec", tag = "2")]
pub ipfs_hash: Vec<u8>,
}
#[derive(Clone, PartialEq, Message)]
pub struct ProfileMixinMessage {
#[prost(int32, tag = "1")]
pub account_type: i32,
#[prost(string, tag = "2")]
pub location: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, prost::Enumeration)]
#[repr(i32)]
pub enum AccountType {
Anon = 0,
Person = 1,
Project = 2,
Organization = 3,
Proxy = 4,
Parody = 5,
Bot = 6,
Shill = 7,
Test = 8,
}
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
schemars::JsonSchema,
serde::Serialize,
serde::Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum ContentType {
#[default]
Document,
Feed,
Comment,
Profile,
Image,
}
#[derive(
Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
)]
pub struct MipmapLevel {
pub filesize: u64,
pub cid: String,
}
#[derive(
Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
)]
pub struct ImageSpec {
pub filename: String,
pub filesize: u64,
pub digest_hex: String,
pub width: u32,
pub height: u32,
pub mipmap_levels: Vec<MipmapLevel>,
}
#[derive(
Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
)]
pub struct ProfileSpec {
pub account_type: i32,
pub location: String,
}
#[derive(
Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
)]
pub struct ImageInput {
pub path: Option<String>,
pub filename: Option<String>,
pub spec: Option<ImageSpec>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PreparedContent {
pub content_type: ContentType,
pub title: Option<String>,
pub body: Option<String>,
pub language: Option<String>,
pub image: Option<ImageSpec>,
pub profile: Option<ProfileSpec>,
}
#[derive(
Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
)]
pub struct ContentInput {
pub content_type: ContentType,
pub title: Option<String>,
pub body: Option<String>,
pub language: Option<String>,
pub image: Option<ImageInput>,
pub profile: Option<ProfileSpec>,
}
impl ContentInput {
#[must_use]
pub fn to_prepared(&self, image: Option<ImageSpec>) -> PreparedContent {
PreparedContent {
content_type: self.content_type,
title: self.title.clone(),
body: self.body.clone(),
language: self.language.clone(),
image,
profile: self.profile.clone(),
}
}
}
#[derive(
Clone, Debug, Default, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
)]
pub struct DecodedItem {
pub content_type: ContentType,
pub title: Option<String>,
pub body: Option<String>,
pub language: Option<String>,
pub image: Option<ImageSpec>,
pub profile: Option<ProfileSpec>,
}
pub fn encode_item(input: &PreparedContent) -> Result<Vec<u8>, crate::ContentError> {
let language = input
.language
.clone()
.unwrap_or_else(|| DEFAULT_LANGUAGE_TAG.to_string());
let mut mixins: Vec<MixinPayloadMessage> = Vec::new();
match input.content_type {
ContentType::Feed => mixins.push(marker(FEED_TYPE_MIXIN_ID)),
ContentType::Comment => mixins.push(marker(COMMENT_TYPE_MIXIN_ID)),
_ => {}
}
mixins.push(MixinPayloadMessage {
mixin_id: LANGUAGE_MIXIN_ID,
payload: LanguageMixinMessage {
language_tag: language,
}
.encode_to_vec(),
});
if let Some(title) = &input.title {
mixins.push(MixinPayloadMessage {
mixin_id: TITLE_MIXIN_ID,
payload: TitleMixinMessage {
title: title.clone(),
}
.encode_to_vec(),
});
}
if let Some(body) = &input.body {
mixins.push(MixinPayloadMessage {
mixin_id: BODY_TEXT_MIXIN_ID,
payload: BodyTextMixinMessage {
body_text: body.clone(),
}
.encode_to_vec(),
});
}
if let Some(image) = &input.image {
mixins.push(MixinPayloadMessage {
mixin_id: IMAGE_MIXIN_ID,
payload: encode_image_mixin(image)?,
});
}
if let Some(profile) = &input.profile {
mixins.push(MixinPayloadMessage {
mixin_id: PROFILE_MIXIN_ID,
payload: ProfileMixinMessage {
account_type: profile.account_type,
location: profile.location.clone(),
}
.encode_to_vec(),
});
}
Ok(ItemMessage {
mixin_payload: mixins,
}
.encode_to_vec())
}
pub fn decode_item(bytes: &[u8]) -> Result<DecodedItem, crate::ContentError> {
let item = ItemMessage::decode(bytes)
.map_err(|e| crate::ContentError::Content(format!("failed to decode item payload: {e}")))?;
let mut out = DecodedItem {
content_type: infer_content_type(&item),
..Default::default()
};
for mixin in &item.mixin_payload {
match mixin.mixin_id {
LANGUAGE_MIXIN_ID => {
out.language = LanguageMixinMessage::decode(mixin.payload.as_slice())
.ok()
.map(|m| m.language_tag);
}
TITLE_MIXIN_ID => {
out.title = TitleMixinMessage::decode(mixin.payload.as_slice())
.ok()
.map(|m| m.title);
}
BODY_TEXT_MIXIN_ID => {
out.body = BodyTextMixinMessage::decode(mixin.payload.as_slice())
.ok()
.map(|m| m.body_text);
}
IMAGE_MIXIN_ID => {
out.image = ImageMixinMessage::decode(mixin.payload.as_slice())
.ok()
.map(decode_image_mixin);
}
PROFILE_MIXIN_ID => {
out.profile = ProfileMixinMessage::decode(mixin.payload.as_slice())
.ok()
.map(|m| ProfileSpec {
account_type: m.account_type,
location: m.location,
});
}
_ => {}
}
}
Ok(out)
}
#[must_use]
pub fn infer_content_type(item: &ItemMessage) -> ContentType {
for mixin in &item.mixin_payload {
match mixin.mixin_id {
FEED_TYPE_MIXIN_ID => return ContentType::Feed,
COMMENT_TYPE_MIXIN_ID => return ContentType::Comment,
PROFILE_MIXIN_ID => return ContentType::Profile,
_ => {}
}
}
if item
.mixin_payload
.iter()
.any(|m| m.mixin_id == IMAGE_MIXIN_ID)
{
return ContentType::Image;
}
ContentType::Document
}
fn marker(mixin_id: u32) -> MixinPayloadMessage {
MixinPayloadMessage {
mixin_id,
payload: Vec::new(),
}
}
pub fn encode_image_mixin(image: &ImageSpec) -> Result<Vec<u8>, crate::ContentError> {
let mipmap_levels = image
.mipmap_levels
.iter()
.map(|l| {
Ok(MipmapLevelMessage {
filesize: l.filesize,
ipfs_hash: cid_to_multihash_bytes(&l.cid)?,
})
})
.collect::<Result<Vec<_>, crate::ContentError>>()?;
let message = ImageMixinMessage {
filename: image.filename.clone(),
filesize: image.filesize,
ipfs_hash: multihash_bytes(&image.digest_hex)?,
width: image.width,
height: image.height,
mipmap_level: mipmap_levels,
};
Ok(message.encode_to_vec())
}
#[must_use]
pub fn decode_image_mixin(msg: ImageMixinMessage) -> ImageSpec {
ImageSpec {
filename: msg.filename,
filesize: msg.filesize,
digest_hex: bytes_to_hex(&digest_from_bytes(&msg.ipfs_hash)),
width: msg.width,
height: msg.height,
mipmap_levels: msg
.mipmap_level
.into_iter()
.map(|l| MipmapLevel {
filesize: l.filesize,
cid: bytes_to_cid(&digest_from_bytes(&l.ipfs_hash)),
})
.collect(),
}
}
fn digest_from_bytes(bytes: &[u8]) -> Vec<u8> {
if bytes.len() == 34 && bytes.first() == Some(&0x12) && bytes.get(1) == Some(&0x20) {
bytes.get(2..).unwrap_or(bytes).to_vec()
} else {
bytes.to_vec()
}
}
#[must_use]
pub fn decode_single_mixin<M>(item: &ItemMessage, mixin_id: u32) -> Option<M>
where
M: Message + Default,
{
item.mixin_payload
.iter()
.find(|m| m.mixin_id == mixin_id)
.and_then(|m| M::decode(m.payload.as_slice()).ok())
}
#[must_use]
pub fn derive_item_id(account_id: [u8; 32], nonce: [u8; 32]) -> [u8; 32] {
let payload = [
parity_scale_codec::Encode::encode(&account_id),
parity_scale_codec::Encode::encode(&nonce),
parity_scale_codec::Encode::encode(&ITEM_ID_NAMESPACE),
]
.concat();
blake2_256(&payload)
}
#[must_use]
pub fn bytes_to_hex(bytes: &[u8]) -> String {
format!("0x{}", hex::encode(bytes))
}
pub fn hex_to_bytes(hex_value: &str) -> Result<[u8; 32], crate::ContentError> {
let raw = hex::decode(hex_value.trim_start_matches("0x"))
.map_err(|_| crate::ContentError::Cid(format!("invalid hex value {hex_value}")))?;
raw.try_into()
.map_err(|_| crate::ContentError::Cid(format!("expected 32 bytes for {hex_value}")))
}
pub fn digest_hex_to_cid(hex_value: &str) -> Result<String, crate::ContentError> {
let digest = hex_to_bytes(hex_value)?;
let mut multihash = Vec::with_capacity(34);
multihash.push(0x12);
multihash.push(0x20);
multihash.extend_from_slice(&digest);
Ok(bs58::encode(multihash).into_string())
}
pub fn cid_to_digest_hex(cid: &str) -> Result<String, crate::ContentError> {
let multihash = bs58::decode(cid)
.into_vec()
.map_err(|_| crate::ContentError::Cid(format!("failed to decode CID {cid}")))?;
if multihash.len() != 34 || multihash.first() != Some(&0x12) || multihash.get(1) != Some(&0x20)
{
return Err(crate::ContentError::Cid(format!(
"CID {cid} is not a sha2-256 CIDv0 multihash"
)));
}
Ok(format!(
"0x{}",
hex::encode(multihash.get(2..).unwrap_or(&multihash))
))
}
fn multihash_bytes(hex_value: &str) -> Result<Vec<u8>, crate::ContentError> {
let digest = hex_to_bytes(hex_value)?;
let mut multihash = Vec::with_capacity(34);
multihash.push(0x12);
multihash.push(0x20);
multihash.extend_from_slice(&digest);
Ok(multihash)
}
fn cid_to_multihash_bytes(cid: &str) -> Result<Vec<u8>, crate::ContentError> {
let digest = cid_to_digest_bytes(cid)?;
let mut multihash = Vec::with_capacity(34);
multihash.push(0x12);
multihash.push(0x20);
multihash.extend_from_slice(&digest);
Ok(multihash)
}
#[must_use]
pub fn bytes_to_cid(digest: &[u8]) -> String {
let mut multihash = Vec::with_capacity(34);
multihash.push(0x12);
multihash.push(0x20);
multihash.extend_from_slice(digest);
bs58::encode(multihash).into_string()
}
fn cid_to_digest_bytes(cid: &str) -> Result<Vec<u8>, crate::ContentError> {
Ok(hex_to_bytes(&cid_to_digest_hex(cid)?)?.to_vec())
}
#[must_use]
pub fn short_hex(value: &str) -> String {
if value.len() <= 18 {
value.to_string()
} else {
let head = value.get(..10).unwrap_or("");
let tail = value.get(value.len().saturating_sub(8)..).unwrap_or("");
format!("{head}...{tail}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_item_id_is_deterministic_and_nonce_sensitive() {
let account = [7u8; 32];
let nonce_a = [1u8; 32];
let nonce_b = [2u8; 32];
assert_eq!(
derive_item_id(account, nonce_a),
derive_item_id(account, nonce_a)
);
assert_ne!(
derive_item_id(account, nonce_a),
derive_item_id(account, nonce_b)
);
assert_ne!(
derive_item_id(account, nonce_a),
derive_item_id([8u8; 32], nonce_a)
);
assert_ne!(derive_item_id(account, nonce_a), [0u8; 32]);
}
#[test]
fn hex_helpers_round_trip_and_validate() {
let bytes = [0xabu8; 32];
let encoded = bytes_to_hex(&bytes);
assert_eq!(encoded, format!("0x{}", "ab".repeat(32)));
assert_eq!(hex_to_bytes(&encoded).unwrap(), bytes);
assert_eq!(hex_to_bytes(encoded.get(2..).unwrap_or("")).unwrap(), bytes);
assert!(hex_to_bytes("0xzz").is_err());
assert!(hex_to_bytes("0x1234").is_err());
}
#[test]
fn cid_helpers_round_trip() {
let digest = format!("0x{}", "11".repeat(32));
let cid = digest_hex_to_cid(&digest).unwrap();
assert_eq!(cid_to_digest_hex(&cid).unwrap(), digest);
assert_eq!(
short_hex(&digest),
format!("0x{}...{}", "11111111", "11111111")
);
}
#[test]
fn cid_rejects_non_sha256_multihash() {
let not_sha256 = bs58::encode(
[0x13u8, 0x20]
.into_iter()
.chain([0u8; 32])
.collect::<Vec<_>>(),
)
.into_string();
assert!(cid_to_digest_hex(¬_sha256).is_err());
}
#[test]
fn image_mixin_stores_reference_multihash_form() {
let digest = format!("0x{}", "22".repeat(32));
let cid = digest_hex_to_cid(&digest).unwrap();
let input = spec_input("photo.jpg", &digest, &cid);
let payload = encode_image_mixin(prepare(&input).image.as_ref().unwrap()).unwrap();
let msg = ImageMixinMessage::decode(payload.as_slice()).unwrap();
let expected_multihash = [[0x12u8, 0x20].as_slice(), &[0x22u8; 32]].concat();
assert_eq!(msg.ipfs_hash, expected_multihash);
assert_eq!(msg.mipmap_level[0].ipfs_hash, expected_multihash);
let decoded = decode_image_mixin(msg);
assert_eq!(decoded.digest_hex, digest);
assert_eq!(decoded.mipmap_levels[0].cid, cid);
}
#[test]
fn decode_image_mixin_accepts_legacy_bare_digests() {
let msg = ImageMixinMessage {
filename: "legacy.jpg".into(),
filesize: 1,
ipfs_hash: vec![0x22; 32],
width: 10,
height: 10,
mipmap_level: vec![MipmapLevelMessage {
filesize: 1,
ipfs_hash: vec![0x22; 32],
}],
};
let decoded = decode_image_mixin(msg);
assert_eq!(decoded.digest_hex, format!("0x{}", "22".repeat(32)));
assert_eq!(
decoded.mipmap_levels[0].cid,
digest_hex_to_cid(&format!("0x{}", "22".repeat(32))).unwrap()
);
}
#[test]
fn encode_decode_document_round_trip() {
let input = ContentInput {
content_type: ContentType::Document,
title: Some("Hello".into()),
body: Some("World".into()),
language: Some("en".into()),
image: None,
profile: None,
};
let bytes = encode_item(&input.to_prepared(None)).unwrap();
let decoded = decode_item(&bytes).unwrap();
assert_eq!(decoded.content_type, ContentType::Document);
assert_eq!(decoded.title.as_deref(), Some("Hello"));
assert_eq!(decoded.body.as_deref(), Some("World"));
assert_eq!(decoded.language.as_deref(), Some("en"));
assert!(decoded.image.is_none());
assert!(decoded.profile.is_none());
}
#[test]
fn encode_decode_feed_includes_marker() {
let input = ContentInput {
content_type: ContentType::Feed,
title: Some("Feed".into()),
body: None,
language: None,
image: None,
profile: None,
};
let item =
ItemMessage::decode(encode_item(&input.to_prepared(None)).unwrap().as_slice()).unwrap();
assert_eq!(infer_content_type(&item), ContentType::Feed);
assert!(
item.mixin_payload
.iter()
.any(|m| m.mixin_id == FEED_TYPE_MIXIN_ID && m.payload.is_empty())
);
assert_eq!(
decode_single_mixin::<LanguageMixinMessage>(&item, LANGUAGE_MIXIN_ID)
.unwrap()
.language_tag,
DEFAULT_LANGUAGE_TAG
);
}
#[test]
fn encode_decode_comment_and_profile_types() {
let comment = ContentInput {
content_type: ContentType::Comment,
title: None,
body: Some("a comment".into()),
language: None,
image: None,
profile: None,
};
let c_item =
ItemMessage::decode(encode_item(&comment.to_prepared(None)).unwrap().as_slice())
.unwrap();
assert_eq!(infer_content_type(&c_item), ContentType::Comment);
let profile = ContentInput {
content_type: ContentType::Profile,
title: Some("Alice".into()),
body: Some("bio".into()),
language: None,
image: None,
profile: Some(ProfileSpec {
account_type: AccountType::Project as i32,
location: "Earth".into(),
}),
};
let p_item =
ItemMessage::decode(encode_item(&profile.to_prepared(None)).unwrap().as_slice())
.unwrap();
assert_eq!(infer_content_type(&p_item), ContentType::Profile);
let decoded = decode_item(&encode_item(&profile.to_prepared(None)).unwrap()).unwrap();
assert_eq!(decoded.content_type, ContentType::Profile);
assert_eq!(decoded.title.as_deref(), Some("Alice"));
assert_eq!(
decoded.profile.as_ref().map(|p| p.location.as_str()),
Some("Earth")
);
assert_eq!(decoded.profile.as_ref().map(|p| p.account_type), Some(2));
}
fn spec_input(filename: &str, digest_hex: &str, cid: &str) -> ContentInput {
ContentInput {
content_type: ContentType::Image,
title: None,
body: None,
language: None,
image: Some(ImageInput {
path: None,
filename: None,
spec: Some(ImageSpec {
filename: filename.into(),
filesize: 12345,
digest_hex: digest_hex.into(),
width: 800,
height: 600,
mipmap_levels: vec![MipmapLevel {
filesize: 100,
cid: cid.into(),
}],
}),
}),
profile: None,
}
}
fn prepare(input: &ContentInput) -> PreparedContent {
input.to_prepared(input.image.as_ref().and_then(|i| i.spec.clone()))
}
#[test]
fn encode_decode_image_round_trip() {
let digest = format!("0x{}", "22".repeat(32));
let input = spec_input("photo.jpg", &digest, &digest_hex_to_cid(&digest).unwrap());
let prepared = prepare(&input);
let item = ItemMessage::decode(encode_item(&prepared).unwrap().as_slice()).unwrap();
assert_eq!(infer_content_type(&item), ContentType::Image);
let decoded = decode_item(&encode_item(&prepared).unwrap()).unwrap();
let image = decoded.image.expect("image should decode");
assert_eq!(image.filename, "photo.jpg");
assert_eq!(image.filesize, 12345);
assert_eq!(image.width, 800);
assert_eq!(image.height, 600);
assert_eq!(image.digest_hex, digest);
assert_eq!(image.mipmap_levels.len(), 1);
assert_eq!(image.mipmap_levels[0].filesize, 100);
assert_eq!(
image.mipmap_levels[0].cid,
digest_hex_to_cid(&digest).unwrap()
);
}
#[test]
fn decode_unknown_mixins_are_ignored() {
let mut item = ItemMessage {
mixin_payload: vec![MixinPayloadMessage {
mixin_id: 0xdead_beef,
payload: vec![1, 2, 3],
}],
};
item.mixin_payload.push(MixinPayloadMessage {
mixin_id: TITLE_MIXIN_ID,
payload: TitleMixinMessage {
title: "kept".into(),
}
.encode_to_vec(),
});
let decoded = decode_item(&item.encode_to_vec()).unwrap();
assert_eq!(decoded.title.as_deref(), Some("kept"));
assert_eq!(decoded.content_type, ContentType::Document);
}
#[test]
fn encode_item_rejects_malformed_image_digest() {
let input = spec_input("bad.jpg", "0xnothex", "unused");
assert!(encode_item(&prepare(&input)).is_err());
}
#[test]
fn encode_item_rejects_malformed_mipmap_cid() {
let input = spec_input("b.jpg", &format!("0x{}", "22".repeat(32)), "not-a-cid");
assert!(encode_item(&prepare(&input)).is_err());
}
}