1use crate::ContentError;
18use crate::chain::{self, ChainAccount};
19use crate::encode::{self, ContentInput, DecodedItem, ImageInput, PreparedContent};
20use crate::indexer::{self, DecodedEvent, QueryKey};
21use rand::Rng;
22
23#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
27pub struct RevisionEntry {
28 pub revision_id: u32,
30 pub ipfs_hash_hex: String,
32 pub block_number: Option<u32>,
34 pub timestamp: Option<u64>,
36}
37
38#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
40pub struct ResolvedItem {
41 pub item_id: String,
43 pub content: DecodedItem,
45 pub revision_id: u32,
47 pub ipfs_hash_hex: String,
49 pub owner: String,
51 pub flags: u8,
53}
54
55#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
57pub struct AccountItem {
58 pub item_id: String,
60 pub title: Option<String>,
62}
63
64#[derive(Clone, Debug, Default, serde::Serialize, schemars::JsonSchema)]
66pub struct ProfileResult {
67 pub exists: bool,
69 pub item_id: Option<String>,
71 pub name: Option<String>,
73 pub bio: Option<String>,
74 pub location: Option<String>,
75 pub account_type: Option<i32>,
76}
77
78#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
80pub struct CoordStatus {
81 pub chain: Option<ChainStatus>,
82 pub indexer: Option<IndexerStatus>,
83 pub ipfs: Option<ipfs::IpfsStatus>,
84}
85
86#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
88pub struct IndexerStatus {
89 pub spans: Vec<Span>,
90}
91
92#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
94pub struct Span {
95 pub start: u32,
96 pub end: u32,
97}
98
99impl From<indexer::IndexStatusResult> for IndexerStatus {
100 fn from(r: indexer::IndexStatusResult) -> Self {
101 IndexerStatus {
102 spans: r
103 .spans
104 .into_iter()
105 .map(|s| Span {
106 start: s.start,
107 end: s.end,
108 })
109 .collect(),
110 }
111 }
112}
113
114#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
116pub struct ChainStatus {
117 pub genesis_hash: String,
118 pub ss58_prefix: u16,
119 pub best_block: u64,
120 pub finalized_block: u64,
121 pub item_id_namespace: u32,
122}
123
124impl From<chain::ChainStatus> for ChainStatus {
125 fn from(s: chain::ChainStatus) -> Self {
127 ChainStatus {
128 genesis_hash: s.genesis_hash,
129 ss58_prefix: s.ss58_prefix,
130 best_block: s.best_block,
131 finalized_block: s.finalized_block,
132 item_id_namespace: s.item_id_namespace,
133 }
134 }
135}
136
137pub fn item(item_id_hex: &str, revision_id: Option<u32>) -> Result<ResolvedItem, ContentError> {
152 let item_id = encode::hex_to_bytes(item_id_hex)?;
153
154 let state = chain::item_state(item_id)?;
156
157 let (revision, ipfs_hash) = resolve_revision(item_id_hex, revision_id)?;
159
160 let bytes = crate::ipfs::cat(&ipfs_hash)?;
162 let content = encode::decode_item(&bytes)?;
163
164 Ok(ResolvedItem {
165 item_id: encode::bytes_to_hex(&item_id),
166 content,
167 revision_id: revision,
168 ipfs_hash_hex: ipfs_hash,
169 owner: state.owner,
170 flags: state.flags,
171 })
172}
173
174#[derive(Clone, Debug)]
176pub struct ItemImage {
177 pub width: u32,
179 pub height: u32,
180 pub level: u32,
182 pub cid: String,
184 pub filesize: u64,
186 pub data: Vec<u8>,
188}
189
190fn select_image_level(
193 image: &encode::ImageSpec,
194 level: Option<u32>,
195) -> Result<(usize, encode::MipmapLevel), crate::ContentError> {
196 if image.mipmap_levels.is_empty() {
197 return Err(crate::ContentError::Content(
201 "image mixin has no mipmap levels".into(),
202 ));
203 }
204 let index = level.unwrap_or(0) as usize;
205 let spec = image.mipmap_levels.get(index).ok_or_else(|| {
206 crate::ContentError::Content(format!(
207 "mipmap level {} out of range (item has {} levels)",
208 index,
209 image.mipmap_levels.len()
210 ))
211 })?;
212 Ok((index, spec.clone()))
213}
214
215pub fn item_image(
228 item_id_hex: &str,
229 revision_id: Option<u32>,
230 level: Option<u32>,
231) -> Result<ItemImage, crate::ContentError> {
232 let (_revision, ipfs_hash) = resolve_revision(item_id_hex, revision_id)?;
233 let bytes = crate::ipfs::cat(&ipfs_hash)?;
234 let content = encode::decode_item(&bytes)?;
235 let image = content.image.ok_or_else(|| {
236 crate::ContentError::Content(format!("item {item_id_hex} has no embedded image"))
237 })?;
238 let (index, spec) = select_image_level(&image, level)?;
239 let data = crate::ipfs::cat_by_cid(&spec.cid)?;
240 #[allow(clippy::cast_possible_truncation)]
244 let level = index as u32;
245 Ok(ItemImage {
246 width: image.width,
247 height: image.height,
248 level,
249 cid: spec.cid,
250 filesize: spec.filesize,
251 data,
252 })
253}
254
255fn revision_entries_from_events(item_id_hex: &str, events: &[DecodedEvent]) -> Vec<RevisionEntry> {
265 events
266 .iter()
267 .filter(|e| e.pallet_name() == "Content" && e.event_name() == "PublishRevision")
268 .filter(|e| {
271 e.field_str("item_id")
272 .is_some_and(|id| id.eq_ignore_ascii_case(item_id_hex))
273 })
274 .filter_map(|e| {
275 #[allow(clippy::cast_possible_truncation)]
279 let rev = e.field_u64("revision_id")? as u32;
280 let hash = e.field_str("ipfs_hash")?.to_string();
281 Some(RevisionEntry {
282 revision_id: rev,
283 ipfs_hash_hex: hash,
284 block_number: Some(e.block_number),
285 timestamp: Some(e.timestamp),
286 })
287 })
288 .collect()
289}
290
291fn sort_revisions_newest_first(revisions: &mut [RevisionEntry]) {
295 revisions.sort_by(|a, b| {
296 b.revision_id
297 .cmp(&a.revision_id)
298 .then_with(|| b.block_number.cmp(&a.block_number))
299 });
300}
301
302fn resolve_revision(
305 item_id_hex: &str,
306 revision_id: Option<u32>,
307) -> Result<(u32, String), ContentError> {
308 let events = indexer::get_events(&indexer::item_id_key(item_id_hex)?, 512, None)?;
309 let mut revisions = revision_entries_from_events(item_id_hex, &events);
310 sort_revisions_newest_first(&mut revisions);
311
312 let entry = match revision_id {
313 Some(target) => revisions
314 .into_iter()
315 .find(|r| r.revision_id == target)
316 .ok_or_else(|| {
317 ContentError::Content(format!(
318 "revision {target} not found for item {item_id_hex}"
319 ))
320 })?,
321 None => revisions.into_iter().next().ok_or_else(|| {
322 ContentError::Content(format!("no indexed revision found for item {item_id_hex}"))
323 })?,
324 };
325 Ok((entry.revision_id, entry.ipfs_hash_hex))
326}
327
328pub fn revisions(item_id_hex: &str) -> Result<Vec<RevisionEntry>, ContentError> {
337 let events = indexer::get_events(&indexer::item_id_key(item_id_hex)?, 512, None)?;
338 let mut list = revision_entries_from_events(item_id_hex, &events);
339 sort_revisions_newest_first(&mut list);
340 Ok(list)
341}
342
343pub fn events(
351 key: &QueryKey,
352 limit: u16,
353 before: Option<(u32, u32)>,
354) -> Result<Vec<DecodedEvent>, ContentError> {
355 indexer::get_events(key, limit, before)
356}
357
358pub fn account_items(account_addr: &str) -> Result<Vec<AccountItem>, ContentError> {
368 let account = chain::account_id_from_address(account_addr)?;
369 let ids = chain::account_item_ids(account)?;
370 let mut out = Vec::with_capacity(ids.len());
371 for id in ids {
372 let id_hex = encode::bytes_to_hex(&id);
373 let title = resolve_title(&id_hex).unwrap_or_default();
374 out.push(AccountItem {
375 item_id: id_hex,
376 title,
377 });
378 }
379 Ok(out)
380}
381
382fn resolve_title(item_id_hex: &str) -> Result<Option<String>, ContentError> {
384 let (_, ipfs_hash) = resolve_revision(item_id_hex, None)?;
385 let bytes = crate::ipfs::cat(&ipfs_hash)?;
386 let content = encode::decode_item(&bytes)?;
387 Ok(content.title)
388}
389
390pub fn profile(account_addr: &str) -> Result<ProfileResult, ContentError> {
399 let account = chain::account_id_from_address(account_addr)?;
400 let Some(profile_item_id) = chain::profile_item(account)? else {
401 return Ok(ProfileResult::default());
402 };
403 let id_hex = encode::bytes_to_hex(&profile_item_id);
404 let resolved = item(&id_hex, None).unwrap_or_else(|_| -> ResolvedItem {
405 ResolvedItem {
408 item_id: id_hex.clone(),
409 content: DecodedItem::default(),
410 revision_id: 0,
411 ipfs_hash_hex: String::new(),
412 owner: String::new(),
413 flags: 0,
414 }
415 });
416 Ok(ProfileResult {
417 exists: true,
418 item_id: Some(id_hex),
419 name: resolved.content.title,
420 bio: resolved.content.body,
421 location: resolved
422 .content
423 .profile
424 .as_ref()
425 .map(|p| p.location.clone()),
426 account_type: resolved.content.profile.as_ref().map(|p| p.account_type),
427 })
428}
429
430pub fn decode_content(ipfs_hash_or_cid: &str) -> Result<DecodedItem, ContentError> {
438 let bytes = if ipfs_hash_or_cid.starts_with("0x") {
439 crate::ipfs::cat(ipfs_hash_or_cid)?
440 } else {
441 crate::ipfs::cat_by_cid(ipfs_hash_or_cid)?
442 };
443 encode::decode_item(&bytes)
444}
445
446pub fn status() -> Result<CoordStatus, ContentError> {
455 let indexer = indexer::index_status().ok().map(IndexerStatus::from);
456 let ipfs = crate::ipfs::id().ok().map(|peer| ipfs::IpfsStatus {
457 peer_id: peer.peer_id,
458 addresses: peer.addresses,
459 });
460 let chain = chain::chain_status().ok().map(ChainStatus::from);
465 Ok(CoordStatus {
466 chain,
467 indexer,
468 ipfs,
469 })
470}
471
472fn resolve_content(input: &ContentInput) -> Result<PreparedContent, ContentError> {
484 let image = match &input.image {
485 Some(ImageInput {
486 path: Some(path),
487 filename,
488 spec: None,
489 }) => Some(crate::image::build_image_spec(
490 std::path::Path::new(path),
491 filename.as_deref(),
492 )?),
493 Some(ImageInput {
494 path: None,
495 spec: Some(spec),
496 ..
497 }) => Some(spec.clone()),
498 Some(ImageInput { path, spec, .. }) => {
499 return Err(ContentError::InvalidArgument(match (path, spec) {
500 (Some(_), Some(_)) => {
501 "image input: supply either `path` or `spec`, not both".into()
502 }
503 _ => "image input: one of `path` or `spec` is required".into(),
504 }));
505 }
506 None => None,
507 };
508 Ok(input.to_prepared(image))
509}
510
511pub fn publish_item(
525 account: &ChainAccount,
526 content: &ContentInput,
527 parents: &[[u8; 32]],
528 links: &[[u8; 32]],
529 mentions: &[[u8; 32]],
530 flags: Option<u8>,
531 nonce: Option<[u8; 32]>,
532) -> Result<chain::TxOutcome, ContentError> {
533 let flags = flags.unwrap_or(crate::config::DEFAULT_ITEM_FLAGS);
534 if flags & !crate::config::VALID_PUBLISH_FLAGS != 0 {
535 return Err(ContentError::InvalidArgument(format!(
536 "invalid item flags: {flags:#x}"
537 )));
538 }
539 let bytes = encode::encode_item(&resolve_content(content)?)?;
540 let ipfs_hash = crate::ipfs::add(&bytes, "content.bin")?;
541 let digest = encode::hex_to_bytes(&ipfs_hash)?;
542
543 let nonce = nonce.unwrap_or_else(|| {
545 let mut n = [0u8; 32];
546 rand::rng().fill_bytes(&mut n);
547 n
548 });
549 let item_id = encode::derive_item_id(account.account_id, nonce);
550
551 let outcome = chain::publish_item(account, nonce, parents, flags, links, mentions, digest)?;
552 if let Some(got) = &outcome.item_id
554 && got != &encode::bytes_to_hex(&item_id)
555 {
556 tracing::warn!(
557 derived = %encode::bytes_to_hex(&item_id),
558 on_chain = %got,
559 "published item id differs from client derivation"
560 );
561 }
562 Ok(outcome)
563}
564
565pub fn publish_revision(
577 account: &ChainAccount,
578 item_id: [u8; 32],
579 content: &ContentInput,
580 links: &[[u8; 32]],
581 mentions: &[[u8; 32]],
582) -> Result<chain::TxOutcome, ContentError> {
583 let bytes = encode::encode_item(&resolve_content(content)?)?;
584 let ipfs_hash = crate::ipfs::add(&bytes, "content.bin")?;
585 let digest = encode::hex_to_bytes(&ipfs_hash)?;
586 chain::publish_revision(account, item_id, links, mentions, digest)
587}
588
589pub fn lifecycle(
600 account: &ChainAccount,
601 action: LifecycleAction,
602 item_id: [u8; 32],
603) -> Result<(), ContentError> {
604 match action {
605 LifecycleAction::Retract => chain::retract_item(account, item_id),
606 LifecycleAction::SetNotRevisionable => chain::set_not_revisionable(account, item_id),
607 LifecycleAction::SetNotRetractable => chain::set_not_retractable(account, item_id),
608 }
609}
610
611pub fn account_link(
623 account: &ChainAccount,
624 action: AccountLinkAction,
625 item_id: [u8; 32],
626) -> Result<(), ContentError> {
627 match action {
628 AccountLinkAction::Add => chain::add_account_item(account, item_id),
629 AccountLinkAction::Remove => chain::remove_account_item(account, item_id),
630 }
631}
632
633pub fn set_profile(
645 account: &ChainAccount,
646 content: &ContentInput,
647) -> Result<chain::TxOutcome, ContentError> {
648 let bytes = encode::encode_item(&resolve_content(content)?)?;
650 let ipfs_hash = crate::ipfs::add(&bytes, "profile.bin")?;
651 let digest = encode::hex_to_bytes(&ipfs_hash)?;
652 let nonce: [u8; 32] = {
653 let mut n = [0u8; 32];
654 rand::rng().fill_bytes(&mut n);
655 n
656 };
657 let item_id = encode::derive_item_id(account.account_id, nonce);
658 let outcome = chain::publish_item(
659 account,
660 nonce,
661 &[],
662 crate::config::DEFAULT_ITEM_FLAGS,
663 &[],
664 &[],
665 digest,
666 )?;
667 chain::set_profile(account, item_id)?;
668 Ok(outcome)
669}
670
671#[derive(
673 Clone, Copy, Debug, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
674)]
675#[serde(rename_all = "snake_case")]
676pub enum LifecycleAction {
677 Retract,
678 SetNotRevisionable,
679 SetNotRetractable,
680}
681
682#[derive(
684 Clone, Copy, Debug, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
685)]
686#[serde(rename_all = "snake_case")]
687pub enum AccountLinkAction {
688 Add,
689 Remove,
690}
691
692pub mod ipfs {
694 #[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
696 pub struct IpfsStatus {
697 pub peer_id: String,
698 pub addresses: Vec<String>,
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 fn sample_image(levels: u32) -> encode::ImageSpec {
707 encode::ImageSpec {
708 width: 1012,
709 height: 1012,
710 mipmap_levels: (0..levels)
711 .map(|i| encode::MipmapLevel {
712 filesize: 121_846 >> i,
713 cid: format!("QmSample{i}"),
714 })
715 .collect(),
716 ..Default::default()
717 }
718 }
719
720 #[test]
721 fn select_image_level_defaults_to_full_res_and_validates() {
722 let image = sample_image(3);
723 let (i, spec) = select_image_level(&image, None).unwrap();
725 assert_eq!(i, 0);
726 assert_eq!(spec.filesize, 121_846);
727 let (i, _) = select_image_level(&image, Some(2)).unwrap();
729 assert_eq!(i, 2);
730 assert!(select_image_level(&image, Some(3)).is_err());
732 assert!(select_image_level(&sample_image(0), None).is_err());
734 }
735
736 #[test]
737 fn lifecycle_and_account_action_serde() {
738 assert_eq!(
739 serde_json::to_value(LifecycleAction::Retract).unwrap(),
740 "retract"
741 );
742 assert_eq!(serde_json::to_value(AccountLinkAction::Add).unwrap(), "add");
743 }
744
745 #[test]
746 fn publish_item_generates_a_nonce_and_derives_id() {
747 let account = [9u8; 32];
750 let nonce = [1u8; 32];
751 let id_a = encode::derive_item_id(account, nonce);
752 let id_b = encode::derive_item_id(account, [2u8; 32]);
753 assert_ne!(id_a, id_b);
754 assert_eq!(id_a, encode::derive_item_id(account, nonce));
755 }
756
757 #[test]
758 fn decode_content_yes_validates_digest_requires_hex() {
759 assert!(encode::hex_to_bytes("0x1234").is_err());
762 }
763
764 fn publish_revision_event(
765 block_number: u32,
766 item_id: &str,
767 revision_id: u32,
768 ipfs_hash: &str,
769 ) -> DecodedEvent {
770 DecodedEvent {
771 block_number,
772 event_index: 1,
773 timestamp: 1_700_000_000_000,
774 event: crate::indexer::StoredEvent {
775 pallet_name: "Content".into(),
776 event_name: "PublishRevision".into(),
777 pallet_index: 7,
778 variant_index: 3,
779 event_index: 1,
780 fields: serde_json::json!({
781 "item_id": item_id,
782 "ipfs_hash": ipfs_hash,
783 "revision_id": revision_id,
784 }),
785 },
786 }
787 }
788
789 #[test]
790 fn revision_entries_exclude_linked_items_revisions() {
791 let own_id = format!("0x{}", "aa".repeat(32));
794 let linked_id = format!("0x{}", "bb".repeat(32));
795 let own_hash = format!("0x{}", "11".repeat(32));
796 let linked_hash = format!("0x{}", "22".repeat(32));
797
798 let events = vec![
800 publish_revision_event(2804, &linked_id, 0, &linked_hash),
801 publish_revision_event(2324, &own_id, 0, &own_hash),
802 ];
803
804 let entries = revision_entries_from_events(&own_id, &events);
805
806 assert_eq!(entries.len(), 1);
807 assert_eq!(entries[0].ipfs_hash_hex, own_hash);
808 assert_eq!(entries[0].revision_id, 0);
809 assert_eq!(entries[0].block_number, Some(2324));
810 }
811
812 #[test]
813 fn revision_entries_ignore_non_revision_and_mismatched_events() {
814 let own_id = format!("0x{}", "aa".repeat(32));
815 let other_id = format!("0x{}", "bb".repeat(32));
816 let mut other_pallet =
817 publish_revision_event(3000, &own_id, 1, &format!("0x{}", "33".repeat(32)));
818 other_pallet.event.pallet_name = "Balances".into();
819
820 let events = vec![
821 other_pallet,
822 publish_revision_event(2900, &other_id, 1, &format!("0x{}", "44".repeat(32))),
823 publish_revision_event(100, &own_id, 0, &format!("0x{}", "11".repeat(32))),
824 ];
825
826 let entries = revision_entries_from_events(&own_id, &events);
827
828 assert_eq!(entries.len(), 1);
829 assert_eq!(entries[0].revision_id, 0);
830 }
831
832 #[test]
833 fn sort_revisions_breaks_equal_revision_id_ties_by_block() {
834 let mut revisions = vec![
835 RevisionEntry {
836 revision_id: 1,
837 ipfs_hash_hex: format!("0x{}", "22".repeat(32)),
838 block_number: Some(2804),
839 timestamp: None,
840 },
841 RevisionEntry {
842 revision_id: 1,
843 ipfs_hash_hex: format!("0x{}", "33".repeat(32)),
844 block_number: Some(3000),
845 timestamp: None,
846 },
847 RevisionEntry {
848 revision_id: 2,
849 ipfs_hash_hex: format!("0x{}", "44".repeat(32)),
850 block_number: Some(100),
851 timestamp: None,
852 },
853 ];
854
855 sort_revisions_newest_first(&mut revisions);
856
857 let hashes: Vec<&str> = revisions.iter().map(|r| r.ipfs_hash_hex.as_str()).collect();
858 assert_eq!(
860 hashes,
861 vec![
862 format!("0x{}", "44".repeat(32)),
863 format!("0x{}", "33".repeat(32)),
864 format!("0x{}", "22".repeat(32)),
865 ]
866 );
867 }
868}