use crate::ContentError;
use crate::chain::{self, ChainAccount};
use crate::encode::{self, ContentInput, DecodedItem, ImageInput, PreparedContent};
use crate::indexer::{self, DecodedEvent, QueryKey};
use rand::Rng;
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct RevisionEntry {
pub revision_id: u32,
pub ipfs_hash_hex: String,
pub block_number: Option<u32>,
pub timestamp: Option<u64>,
}
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct ResolvedItem {
pub item_id: String,
pub content: DecodedItem,
pub revision_id: u32,
pub ipfs_hash_hex: String,
pub owner: String,
pub flags: u8,
}
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct AccountItem {
pub item_id: String,
pub title: Option<String>,
}
#[derive(Clone, Debug, Default, serde::Serialize, schemars::JsonSchema)]
pub struct ProfileResult {
pub exists: bool,
pub item_id: Option<String>,
pub name: Option<String>,
pub bio: Option<String>,
pub location: Option<String>,
pub account_type: Option<i32>,
}
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct CoordStatus {
pub chain: Option<ChainStatus>,
pub indexer: Option<IndexerStatus>,
pub ipfs: Option<ipfs::IpfsStatus>,
}
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct IndexerStatus {
pub spans: Vec<Span>,
}
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct Span {
pub start: u32,
pub end: u32,
}
impl From<indexer::IndexStatusResult> for IndexerStatus {
fn from(r: indexer::IndexStatusResult) -> Self {
IndexerStatus {
spans: r
.spans
.into_iter()
.map(|s| Span {
start: s.start,
end: s.end,
})
.collect(),
}
}
}
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct ChainStatus {
pub genesis_hash: String,
pub ss58_prefix: u16,
pub best_block: u64,
pub finalized_block: u64,
pub item_id_namespace: u32,
}
impl From<chain::ChainStatus> for ChainStatus {
fn from(s: chain::ChainStatus) -> Self {
ChainStatus {
genesis_hash: s.genesis_hash,
ss58_prefix: s.ss58_prefix,
best_block: s.best_block,
finalized_block: s.finalized_block,
item_id_namespace: s.item_id_namespace,
}
}
}
pub fn item(item_id_hex: &str, revision_id: Option<u32>) -> Result<ResolvedItem, ContentError> {
let item_id = encode::hex_to_bytes(item_id_hex)?;
let state = chain::item_state(item_id)?;
let (revision, ipfs_hash) = resolve_revision(item_id_hex, revision_id)?;
let bytes = crate::ipfs::cat(&ipfs_hash)?;
let content = encode::decode_item(&bytes)?;
Ok(ResolvedItem {
item_id: encode::bytes_to_hex(&item_id),
content,
revision_id: revision,
ipfs_hash_hex: ipfs_hash,
owner: state.owner,
flags: state.flags,
})
}
#[derive(Clone, Debug)]
pub struct ItemImage {
pub width: u32,
pub height: u32,
pub level: u32,
pub cid: String,
pub filesize: u64,
pub data: Vec<u8>,
}
fn select_image_level(
image: &encode::ImageSpec,
level: Option<u32>,
) -> Result<(usize, encode::MipmapLevel), crate::ContentError> {
if image.mipmap_levels.is_empty() {
return Err(crate::ContentError::Content(
"image mixin has no mipmap levels".into(),
));
}
let index = level.unwrap_or(0) as usize;
let spec = image.mipmap_levels.get(index).ok_or_else(|| {
crate::ContentError::Content(format!(
"mipmap level {} out of range (item has {} levels)",
index,
image.mipmap_levels.len()
))
})?;
Ok((index, spec.clone()))
}
pub fn item_image(
item_id_hex: &str,
revision_id: Option<u32>,
level: Option<u32>,
) -> Result<ItemImage, crate::ContentError> {
let (_revision, ipfs_hash) = resolve_revision(item_id_hex, revision_id)?;
let bytes = crate::ipfs::cat(&ipfs_hash)?;
let content = encode::decode_item(&bytes)?;
let image = content.image.ok_or_else(|| {
crate::ContentError::Content(format!("item {item_id_hex} has no embedded image"))
})?;
let (index, spec) = select_image_level(&image, level)?;
let data = crate::ipfs::cat_by_cid(&spec.cid)?;
#[allow(clippy::cast_possible_truncation)]
let level = index as u32;
Ok(ItemImage {
width: image.width,
height: image.height,
level,
cid: spec.cid,
filesize: spec.filesize,
data,
})
}
fn revision_entries_from_events(item_id_hex: &str, events: &[DecodedEvent]) -> Vec<RevisionEntry> {
events
.iter()
.filter(|e| e.pallet_name() == "Content" && e.event_name() == "PublishRevision")
.filter(|e| {
e.field_str("item_id")
.is_some_and(|id| id.eq_ignore_ascii_case(item_id_hex))
})
.filter_map(|e| {
#[allow(clippy::cast_possible_truncation)]
let rev = e.field_u64("revision_id")? as u32;
let hash = e.field_str("ipfs_hash")?.to_string();
Some(RevisionEntry {
revision_id: rev,
ipfs_hash_hex: hash,
block_number: Some(e.block_number),
timestamp: Some(e.timestamp),
})
})
.collect()
}
fn sort_revisions_newest_first(revisions: &mut [RevisionEntry]) {
revisions.sort_by(|a, b| {
b.revision_id
.cmp(&a.revision_id)
.then_with(|| b.block_number.cmp(&a.block_number))
});
}
fn resolve_revision(
item_id_hex: &str,
revision_id: Option<u32>,
) -> Result<(u32, String), ContentError> {
let events = indexer::get_events(&indexer::item_id_key(item_id_hex)?, 512, None)?;
let mut revisions = revision_entries_from_events(item_id_hex, &events);
sort_revisions_newest_first(&mut revisions);
let entry = match revision_id {
Some(target) => revisions
.into_iter()
.find(|r| r.revision_id == target)
.ok_or_else(|| {
ContentError::Content(format!(
"revision {target} not found for item {item_id_hex}"
))
})?,
None => revisions.into_iter().next().ok_or_else(|| {
ContentError::Content(format!("no indexed revision found for item {item_id_hex}"))
})?,
};
Ok((entry.revision_id, entry.ipfs_hash_hex))
}
pub fn revisions(item_id_hex: &str) -> Result<Vec<RevisionEntry>, ContentError> {
let events = indexer::get_events(&indexer::item_id_key(item_id_hex)?, 512, None)?;
let mut list = revision_entries_from_events(item_id_hex, &events);
sort_revisions_newest_first(&mut list);
Ok(list)
}
pub fn events(
key: &QueryKey,
limit: u16,
before: Option<(u32, u32)>,
) -> Result<Vec<DecodedEvent>, ContentError> {
indexer::get_events(key, limit, before)
}
pub fn account_items(account_addr: &str) -> Result<Vec<AccountItem>, ContentError> {
let account = chain::account_id_from_address(account_addr)?;
let ids = chain::account_item_ids(account)?;
let mut out = Vec::with_capacity(ids.len());
for id in ids {
let id_hex = encode::bytes_to_hex(&id);
let title = resolve_title(&id_hex).unwrap_or_default();
out.push(AccountItem {
item_id: id_hex,
title,
});
}
Ok(out)
}
fn resolve_title(item_id_hex: &str) -> Result<Option<String>, ContentError> {
let (_, ipfs_hash) = resolve_revision(item_id_hex, None)?;
let bytes = crate::ipfs::cat(&ipfs_hash)?;
let content = encode::decode_item(&bytes)?;
Ok(content.title)
}
pub fn profile(account_addr: &str) -> Result<ProfileResult, ContentError> {
let account = chain::account_id_from_address(account_addr)?;
let Some(profile_item_id) = chain::profile_item(account)? else {
return Ok(ProfileResult::default());
};
let id_hex = encode::bytes_to_hex(&profile_item_id);
let resolved = item(&id_hex, None).unwrap_or_else(|_| -> ResolvedItem {
ResolvedItem {
item_id: id_hex.clone(),
content: DecodedItem::default(),
revision_id: 0,
ipfs_hash_hex: String::new(),
owner: String::new(),
flags: 0,
}
});
Ok(ProfileResult {
exists: true,
item_id: Some(id_hex),
name: resolved.content.title,
bio: resolved.content.body,
location: resolved
.content
.profile
.as_ref()
.map(|p| p.location.clone()),
account_type: resolved.content.profile.as_ref().map(|p| p.account_type),
})
}
pub fn decode_content(ipfs_hash_or_cid: &str) -> Result<DecodedItem, ContentError> {
let bytes = if ipfs_hash_or_cid.starts_with("0x") {
crate::ipfs::cat(ipfs_hash_or_cid)?
} else {
crate::ipfs::cat_by_cid(ipfs_hash_or_cid)?
};
encode::decode_item(&bytes)
}
pub fn status() -> Result<CoordStatus, ContentError> {
let indexer = indexer::index_status().ok().map(IndexerStatus::from);
let ipfs = crate::ipfs::id().ok().map(|peer| ipfs::IpfsStatus {
peer_id: peer.peer_id,
addresses: peer.addresses,
});
let chain = chain::chain_status().ok().map(ChainStatus::from);
Ok(CoordStatus {
chain,
indexer,
ipfs,
})
}
fn resolve_content(input: &ContentInput) -> Result<PreparedContent, ContentError> {
let image = match &input.image {
Some(ImageInput {
path: Some(path),
filename,
spec: None,
}) => Some(crate::image::build_image_spec(
std::path::Path::new(path),
filename.as_deref(),
)?),
Some(ImageInput {
path: None,
spec: Some(spec),
..
}) => Some(spec.clone()),
Some(ImageInput { path, spec, .. }) => {
return Err(ContentError::InvalidArgument(match (path, spec) {
(Some(_), Some(_)) => {
"image input: supply either `path` or `spec`, not both".into()
}
_ => "image input: one of `path` or `spec` is required".into(),
}));
}
None => None,
};
Ok(input.to_prepared(image))
}
pub fn publish_item(
account: &ChainAccount,
content: &ContentInput,
parents: &[[u8; 32]],
links: &[[u8; 32]],
mentions: &[[u8; 32]],
flags: Option<u8>,
nonce: Option<[u8; 32]>,
) -> Result<chain::TxOutcome, ContentError> {
let flags = flags.unwrap_or(crate::config::DEFAULT_ITEM_FLAGS);
if flags & !crate::config::VALID_PUBLISH_FLAGS != 0 {
return Err(ContentError::InvalidArgument(format!(
"invalid item flags: {flags:#x}"
)));
}
let bytes = encode::encode_item(&resolve_content(content)?)?;
let ipfs_hash = crate::ipfs::add(&bytes, "content.bin")?;
let digest = encode::hex_to_bytes(&ipfs_hash)?;
let nonce = nonce.unwrap_or_else(|| {
let mut n = [0u8; 32];
rand::rng().fill_bytes(&mut n);
n
});
let item_id = encode::derive_item_id(account.account_id, nonce);
let outcome = chain::publish_item(account, nonce, parents, flags, links, mentions, digest)?;
if let Some(got) = &outcome.item_id
&& got != &encode::bytes_to_hex(&item_id)
{
tracing::warn!(
derived = %encode::bytes_to_hex(&item_id),
on_chain = %got,
"published item id differs from client derivation"
);
}
Ok(outcome)
}
pub fn publish_revision(
account: &ChainAccount,
item_id: [u8; 32],
content: &ContentInput,
links: &[[u8; 32]],
mentions: &[[u8; 32]],
) -> Result<chain::TxOutcome, ContentError> {
let bytes = encode::encode_item(&resolve_content(content)?)?;
let ipfs_hash = crate::ipfs::add(&bytes, "content.bin")?;
let digest = encode::hex_to_bytes(&ipfs_hash)?;
chain::publish_revision(account, item_id, links, mentions, digest)
}
pub fn lifecycle(
account: &ChainAccount,
action: LifecycleAction,
item_id: [u8; 32],
) -> Result<(), ContentError> {
match action {
LifecycleAction::Retract => chain::retract_item(account, item_id),
LifecycleAction::SetNotRevisionable => chain::set_not_revisionable(account, item_id),
LifecycleAction::SetNotRetractable => chain::set_not_retractable(account, item_id),
}
}
pub fn account_link(
account: &ChainAccount,
action: AccountLinkAction,
item_id: [u8; 32],
) -> Result<(), ContentError> {
match action {
AccountLinkAction::Add => chain::add_account_item(account, item_id),
AccountLinkAction::Remove => chain::remove_account_item(account, item_id),
}
}
pub fn set_profile(
account: &ChainAccount,
content: &ContentInput,
) -> Result<chain::TxOutcome, ContentError> {
let bytes = encode::encode_item(&resolve_content(content)?)?;
let ipfs_hash = crate::ipfs::add(&bytes, "profile.bin")?;
let digest = encode::hex_to_bytes(&ipfs_hash)?;
let nonce: [u8; 32] = {
let mut n = [0u8; 32];
rand::rng().fill_bytes(&mut n);
n
};
let item_id = encode::derive_item_id(account.account_id, nonce);
let outcome = chain::publish_item(
account,
nonce,
&[],
crate::config::DEFAULT_ITEM_FLAGS,
&[],
&[],
digest,
)?;
chain::set_profile(account, item_id)?;
Ok(outcome)
}
#[derive(
Clone, Copy, Debug, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleAction {
Retract,
SetNotRevisionable,
SetNotRetractable,
}
#[derive(
Clone, Copy, Debug, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum AccountLinkAction {
Add,
Remove,
}
pub mod ipfs {
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct IpfsStatus {
pub peer_id: String,
pub addresses: Vec<String>,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_image(levels: u32) -> encode::ImageSpec {
encode::ImageSpec {
width: 1012,
height: 1012,
mipmap_levels: (0..levels)
.map(|i| encode::MipmapLevel {
filesize: 121_846 >> i,
cid: format!("QmSample{i}"),
})
.collect(),
..Default::default()
}
}
#[test]
fn select_image_level_defaults_to_full_res_and_validates() {
let image = sample_image(3);
let (i, spec) = select_image_level(&image, None).unwrap();
assert_eq!(i, 0);
assert_eq!(spec.filesize, 121_846);
let (i, _) = select_image_level(&image, Some(2)).unwrap();
assert_eq!(i, 2);
assert!(select_image_level(&image, Some(3)).is_err());
assert!(select_image_level(&sample_image(0), None).is_err());
}
#[test]
fn lifecycle_and_account_action_serde() {
assert_eq!(
serde_json::to_value(LifecycleAction::Retract).unwrap(),
"retract"
);
assert_eq!(serde_json::to_value(AccountLinkAction::Add).unwrap(), "add");
}
#[test]
fn publish_item_generates_a_nonce_and_derives_id() {
let account = [9u8; 32];
let nonce = [1u8; 32];
let id_a = encode::derive_item_id(account, nonce);
let id_b = encode::derive_item_id(account, [2u8; 32]);
assert_ne!(id_a, id_b);
assert_eq!(id_a, encode::derive_item_id(account, nonce));
}
#[test]
fn decode_content_yes_validates_digest_requires_hex() {
assert!(encode::hex_to_bytes("0x1234").is_err());
}
fn publish_revision_event(
block_number: u32,
item_id: &str,
revision_id: u32,
ipfs_hash: &str,
) -> DecodedEvent {
DecodedEvent {
block_number,
event_index: 1,
timestamp: 1_700_000_000_000,
event: crate::indexer::StoredEvent {
pallet_name: "Content".into(),
event_name: "PublishRevision".into(),
pallet_index: 7,
variant_index: 3,
event_index: 1,
fields: serde_json::json!({
"item_id": item_id,
"ipfs_hash": ipfs_hash,
"revision_id": revision_id,
}),
},
}
}
#[test]
fn revision_entries_exclude_linked_items_revisions() {
let own_id = format!("0x{}", "aa".repeat(32));
let linked_id = format!("0x{}", "bb".repeat(32));
let own_hash = format!("0x{}", "11".repeat(32));
let linked_hash = format!("0x{}", "22".repeat(32));
let events = vec![
publish_revision_event(2804, &linked_id, 0, &linked_hash),
publish_revision_event(2324, &own_id, 0, &own_hash),
];
let entries = revision_entries_from_events(&own_id, &events);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].ipfs_hash_hex, own_hash);
assert_eq!(entries[0].revision_id, 0);
assert_eq!(entries[0].block_number, Some(2324));
}
#[test]
fn revision_entries_ignore_non_revision_and_mismatched_events() {
let own_id = format!("0x{}", "aa".repeat(32));
let other_id = format!("0x{}", "bb".repeat(32));
let mut other_pallet =
publish_revision_event(3000, &own_id, 1, &format!("0x{}", "33".repeat(32)));
other_pallet.event.pallet_name = "Balances".into();
let events = vec![
other_pallet,
publish_revision_event(2900, &other_id, 1, &format!("0x{}", "44".repeat(32))),
publish_revision_event(100, &own_id, 0, &format!("0x{}", "11".repeat(32))),
];
let entries = revision_entries_from_events(&own_id, &events);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].revision_id, 0);
}
#[test]
fn sort_revisions_breaks_equal_revision_id_ties_by_block() {
let mut revisions = vec![
RevisionEntry {
revision_id: 1,
ipfs_hash_hex: format!("0x{}", "22".repeat(32)),
block_number: Some(2804),
timestamp: None,
},
RevisionEntry {
revision_id: 1,
ipfs_hash_hex: format!("0x{}", "33".repeat(32)),
block_number: Some(3000),
timestamp: None,
},
RevisionEntry {
revision_id: 2,
ipfs_hash_hex: format!("0x{}", "44".repeat(32)),
block_number: Some(100),
timestamp: None,
},
];
sort_revisions_newest_first(&mut revisions);
let hashes: Vec<&str> = revisions.iter().map(|r| r.ipfs_hash_hex.as_str()).collect();
assert_eq!(
hashes,
vec![
format!("0x{}", "44".repeat(32)),
format!("0x{}", "33".repeat(32)),
format!("0x{}", "22".repeat(32)),
]
);
}
}