Adds the on-wire shapes for FoF Mode 2 comment-gating per
docs/fof-spec/layer-2-mode2-fof-comments.md:
- WrapSlot: per-V_x slot with 2B prefilter_tag + 48B read_ciphertext
+ 48B sign_ciphertext (sealed CEK + sealed priv_x_seed). 98 bytes
total per slot. Receiver trial-decrypts via prefilter match.
- FoFCommentGating: author-published gating block embedded in
Post.fof_gating. Carries slot_binder_nonce (32B random; replaces
spec's circular "post_id in HKDF info"), pub_post_set (1:1 with
wrap_slots, includes dummy pubkeys), wrap_slots, and revocation_list
(initially empty; revocation diffs accumulate on the BlobHeader copy).
- RevocationEntry: author-signed entry triggering retroactive comment
delete + pub_post_set removal on every file-holder that receives it.
- CommentPermission gains FriendsOfFriends variant. Existing match arm
in connection.rs handle-incoming-diff path is extended with a
"drop pending CDN four-check verification" stub (full verify in a
later slice).
- InlineComment extended with three optional fields:
pub_x_index: index into parent post's pub_post_set
group_sig: 64B ed25519 sig under priv_x
encrypted_payload: ChaCha20-Poly1305 ciphertext under CEK_comments
All #[serde(default)] for back-compat. Old comments deserialize
cleanly with None.
- Post gains optional fof_gating field for the author-signed snapshot
at publish time. PostId = BLAKE3(Post) covers it, so any tampering
is detectable. Mutations (revocation, access-grant) arrive later as
diffs against the local BlobHeader copy.
All 21 existing Post construction sites + 4 existing InlineComment
sites updated via perl -0pe sweeps to pass None for the new fields.
Full test suite: 134/134 pass (4 new slot crypto + 130 existing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
792 lines
32 KiB
Rust
792 lines
32 KiB
Rust
//! Import data from ZIP archives exported by the export module.
|
|
//!
|
|
//! Import actions:
|
|
//! - AddAsIdentity: create a new identity from the export's key + data
|
|
//! - ImportPublicPosts: import only public posts into the current identity (new PostIds)
|
|
//! - MergeWithKey: decrypt encrypted posts using provided key, re-encrypt for current identity
|
|
|
|
use std::io::Read;
|
|
use std::path::Path;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::{debug, info, warn};
|
|
|
|
use crate::blob::BlobStore;
|
|
use crate::content::compute_post_id;
|
|
use crate::export::{ExportManifest, ExportedPost};
|
|
use crate::storage::StoragePool;
|
|
use crate::types::{Attachment, NodeId, Post, PostVisibility, PostingIdentity, VisibilityIntent};
|
|
|
|
/// Parse the Debug-formatted intent string that the export writes
|
|
/// (`format!("{:?}", visibility_intent)`) back into a `VisibilityIntent`.
|
|
///
|
|
/// Falls back to a visibility-shape heuristic when the export carries no
|
|
/// intent (pre-v0.6.1 source DBs never populated the intent column, so the
|
|
/// export writes `None`). Without this fallback, old exports imported as a
|
|
/// persona would bucket every encrypted post under `Friends` and DMs would
|
|
/// silently disappear from the Messages tab.
|
|
fn parse_exported_intent(raw: Option<&str>, vis: &PostVisibility) -> VisibilityIntent {
|
|
// Try a clean prefix match on the Debug form first.
|
|
if let Some(s) = raw {
|
|
let s = s.trim();
|
|
if s == "Public" { return VisibilityIntent::Public; }
|
|
if s == "Friends" { return VisibilityIntent::Friends; }
|
|
if s.starts_with("Circle(") {
|
|
// `Circle("name")` — pull the quoted string.
|
|
if let Some(q) = s.find('"') {
|
|
if let Some(end) = s.rfind('"') {
|
|
if end > q {
|
|
let name = &s[q + 1..end];
|
|
return VisibilityIntent::Circle(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
return VisibilityIntent::Circle(String::new());
|
|
}
|
|
if s.starts_with("Direct(") {
|
|
// Debug form of Vec<NodeId> is messy to parse reliably. Recover
|
|
// the recipient list from the PostVisibility if available; the
|
|
// set is semantically equivalent (both derive from the same
|
|
// wrapping list).
|
|
if let PostVisibility::Encrypted { recipients } = vis {
|
|
let ids: Vec<NodeId> = recipients.iter().map(|wk| wk.recipient).collect();
|
|
return VisibilityIntent::Direct(ids);
|
|
}
|
|
return VisibilityIntent::Direct(vec![]);
|
|
}
|
|
if s == "Control" { return VisibilityIntent::Control; }
|
|
if s == "Profile" { return VisibilityIntent::Profile; }
|
|
if s == "Announcement" { return VisibilityIntent::Announcement; }
|
|
if s == "GroupKeyDistribute" { return VisibilityIntent::GroupKeyDistribute; }
|
|
}
|
|
// No intent recorded — infer from the visibility shape.
|
|
match vis {
|
|
PostVisibility::Public => VisibilityIntent::Public,
|
|
PostVisibility::Encrypted { recipients } => {
|
|
// Heuristic: DMs typically wrap to 1-2 people (recipient + self);
|
|
// Friends posts wrap to every public follow (usually many).
|
|
// Use 3 as a threshold that treats small-group conversations as
|
|
// Direct while letting broadcast-to-friends stay Friends.
|
|
//
|
|
// Rationale: the pre-intent export was built before we could
|
|
// distinguish these cases, so we prefer a best-effort partition
|
|
// that gets DMs into the Messages tab (where the user will look
|
|
// for them) rather than filing them under Friends.
|
|
if recipients.len() <= 3 {
|
|
VisibilityIntent::Direct(recipients.iter().map(|wk| wk.recipient).collect())
|
|
} else {
|
|
VisibilityIntent::Friends
|
|
}
|
|
}
|
|
PostVisibility::GroupEncrypted { .. } => {
|
|
// No way to recover the circle name from wire visibility alone.
|
|
VisibilityIntent::Circle(String::new())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract posting_identities.json from an export ZIP and upsert each entry
|
|
/// into storage. Called during import so multi-persona users restore all
|
|
/// their posting keys. Idempotent — INSERT OR IGNORE on conflict. No-op if
|
|
/// the file is missing (pre-0.6.3 bundle).
|
|
pub async fn restore_posting_identities(
|
|
zip_path: &Path,
|
|
storage: &StoragePool,
|
|
) -> anyhow::Result<usize> {
|
|
let zip_path = zip_path.to_path_buf();
|
|
let identities: Vec<PostingIdentity> = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<PostingIdentity>> {
|
|
let file = std::fs::File::open(&zip_path)?;
|
|
let mut archive = zip::ZipArchive::new(file)?;
|
|
let buf = {
|
|
let mut entry = match archive.by_name("itsgoin-export/posting_identities.json") {
|
|
Ok(e) => e,
|
|
Err(_) => return Ok(Vec::new()),
|
|
};
|
|
let mut s = String::new();
|
|
entry.read_to_string(&mut s)?;
|
|
s
|
|
};
|
|
Ok(serde_json::from_str(&buf).unwrap_or_default())
|
|
}).await??;
|
|
|
|
let s = storage.get().await;
|
|
let mut restored = 0usize;
|
|
for id in &identities {
|
|
s.upsert_posting_identity(id)?;
|
|
restored += 1;
|
|
}
|
|
Ok(restored)
|
|
}
|
|
|
|
/// What to do with the imported data.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ImportAction {
|
|
/// Create a new identity from the export's key and restore all data.
|
|
AddAsIdentity,
|
|
/// Import public posts into the current identity with new PostIds.
|
|
ImportPublicPosts,
|
|
/// Decrypt with the provided key, re-create posts under current identity.
|
|
MergeWithKey { key_hex: String },
|
|
}
|
|
|
|
/// Summary of what an import ZIP contains (shown to user before importing).
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct ImportSummary {
|
|
pub node_id: String,
|
|
pub scope: String,
|
|
pub export_date: u64,
|
|
pub post_count: usize,
|
|
pub blob_count: usize,
|
|
pub has_identity_key: bool,
|
|
pub has_follows: bool,
|
|
pub has_settings: bool,
|
|
}
|
|
|
|
/// Result of an import operation.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct ImportResult {
|
|
pub posts_imported: usize,
|
|
pub posts_skipped: usize,
|
|
pub blobs_imported: usize,
|
|
pub message: String,
|
|
}
|
|
|
|
/// Read a ZIP and return a summary of its contents (without importing).
|
|
pub fn read_import_summary(zip_path: &Path) -> anyhow::Result<ImportSummary> {
|
|
let file = std::fs::File::open(zip_path)?;
|
|
let mut archive = zip::ZipArchive::new(file)?;
|
|
|
|
// Read manifest
|
|
let manifest: ExportManifest = {
|
|
let mut entry = archive.by_name("itsgoin-export/manifest.json")?;
|
|
let mut buf = String::new();
|
|
entry.read_to_string(&mut buf)?;
|
|
serde_json::from_str(&buf)?
|
|
};
|
|
|
|
let has_key = archive.by_name("itsgoin-export/identity.key").is_ok();
|
|
let has_follows = archive.by_name("itsgoin-export/follows.json").is_ok();
|
|
let has_settings = archive.by_name("itsgoin-export/settings.json").is_ok();
|
|
|
|
Ok(ImportSummary {
|
|
node_id: manifest.node_id,
|
|
scope: format!("{:?}", manifest.scope),
|
|
export_date: manifest.export_date,
|
|
post_count: manifest.post_count,
|
|
blob_count: manifest.blob_count,
|
|
has_identity_key: has_key,
|
|
has_follows,
|
|
has_settings,
|
|
})
|
|
}
|
|
|
|
/// Parsed data from a ZIP ready for async import.
|
|
struct ParsedImport {
|
|
posts: Vec<(Post, PostVisibility, Vec<(Attachment, Vec<u8>)>)>,
|
|
skipped: usize,
|
|
}
|
|
|
|
/// Staged content from a bundle, ready to write into the current identity
|
|
/// without reparenting. Posts keep their original author/post_id/signatures;
|
|
/// the bundle's posting keys become personas on this device.
|
|
struct StagedImport {
|
|
/// Posting identities to register (includes the bundle's identity.key and
|
|
/// any entries from posting_identities.json). Deduped by node_id.
|
|
posting_identities: Vec<PostingIdentity>,
|
|
/// Posts in the form (post_id, Post, PostVisibility, intent, blobs).
|
|
posts: Vec<(crate::types::PostId, Post, PostVisibility, crate::types::VisibilityIntent, Vec<(Attachment, Vec<u8>)>)>,
|
|
/// Follows to add to current identity's follow list.
|
|
follows: Vec<NodeId>,
|
|
/// Profiles to upsert (keyed by their own node_id, which becomes one of
|
|
/// our personas or a remote peer).
|
|
profiles: Vec<crate::types::PublicProfile>,
|
|
}
|
|
|
|
/// Import a bundle as personas: add the source's posting keys to our
|
|
/// `posting_identities`, and insert their posts AS-AUTHORED (no reparenting).
|
|
/// Content encrypted to any of the imported keys becomes decryptable because
|
|
/// we now hold those secrets. Idempotent — duplicate post ids / posting keys
|
|
/// are skipped via ON CONFLICT handling.
|
|
pub async fn import_as_personas(
|
|
zip_path: &Path,
|
|
storage: &StoragePool,
|
|
blob_store: &BlobStore,
|
|
) -> anyhow::Result<ImportResult> {
|
|
let staged = {
|
|
let zip_path = zip_path.to_path_buf();
|
|
tokio::task::spawn_blocking(move || -> anyhow::Result<StagedImport> {
|
|
let file = std::fs::File::open(&zip_path)?;
|
|
let mut archive = zip::ZipArchive::new(file)?;
|
|
|
|
// identity.key — the source device's primary key, which now
|
|
// becomes a posting persona on our device.
|
|
let mut posting_identities: Vec<PostingIdentity> = Vec::new();
|
|
if let Ok(mut entry) = archive.by_name("itsgoin-export/identity.key") {
|
|
let mut key_bytes = Vec::new();
|
|
entry.read_to_end(&mut key_bytes)?;
|
|
if key_bytes.len() == 32 {
|
|
let seed: [u8; 32] = key_bytes.as_slice().try_into().unwrap();
|
|
let sk = iroh::SecretKey::from_bytes(&seed);
|
|
let nid: NodeId = *sk.public().as_bytes();
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
posting_identities.push(PostingIdentity {
|
|
node_id: nid,
|
|
secret_seed: seed,
|
|
display_name: String::new(),
|
|
created_at: now,
|
|
});
|
|
}
|
|
}
|
|
|
|
// posting_identities.json (v0.6+ bundles) — additional personas.
|
|
if let Ok(mut entry) = archive.by_name("itsgoin-export/posting_identities.json") {
|
|
let mut buf = String::new();
|
|
entry.read_to_string(&mut buf)?;
|
|
if let Ok(ids) = serde_json::from_str::<Vec<PostingIdentity>>(&buf) {
|
|
for id in ids {
|
|
if !posting_identities.iter().any(|p| p.node_id == id.node_id) {
|
|
posting_identities.push(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// posts.json
|
|
let posts_raw: Vec<ExportedPost> = match archive.by_name("itsgoin-export/posts.json") {
|
|
Ok(mut entry) => {
|
|
let mut buf = String::new();
|
|
entry.read_to_string(&mut buf)?;
|
|
serde_json::from_str(&buf).unwrap_or_default()
|
|
}
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
let mut staged_posts = Vec::new();
|
|
for ep in &posts_raw {
|
|
let id_bytes = hex::decode(&ep.id).unwrap_or_default();
|
|
let post_id: crate::types::PostId = match id_bytes.as_slice().try_into() {
|
|
Ok(id) => id,
|
|
Err(_) => continue,
|
|
};
|
|
let author_bytes = hex::decode(&ep.author).unwrap_or_default();
|
|
let author: NodeId = match author_bytes.as_slice().try_into() {
|
|
Ok(a) => a,
|
|
Err(_) => continue,
|
|
};
|
|
let attachments: Vec<Attachment> = serde_json::from_str(&ep.attachments_json)
|
|
.unwrap_or_default();
|
|
let vis: PostVisibility = serde_json::from_str(&ep.visibility_json)
|
|
.unwrap_or(PostVisibility::Public);
|
|
let post = Post {
|
|
author,
|
|
content: ep.content.clone(),
|
|
attachments: attachments.clone(),
|
|
timestamp_ms: ep.timestamp_ms,
|
|
fof_gating: None,
|
|
};
|
|
|
|
// Preserve the original visibility intent from the export.
|
|
// Export stored it as a Debug-format string
|
|
// (`format!("{:?}", intent)`), so we parse a prefix match.
|
|
// When the export predates intent storage (source DB never
|
|
// populated `visibility_intent`), fall back to a heuristic
|
|
// based on the visibility shape.
|
|
let intent = parse_exported_intent(ep.intent.as_deref(), &vis);
|
|
|
|
// Read attached blobs
|
|
let mut blobs = Vec::new();
|
|
for att in &attachments {
|
|
let path = format!("itsgoin-export/blobs/{}", hex::encode(att.cid));
|
|
if let Ok(mut blob_entry) = archive.by_name(&path) {
|
|
let mut data = Vec::new();
|
|
blob_entry.read_to_end(&mut data)?;
|
|
blobs.push((att.clone(), data));
|
|
}
|
|
}
|
|
staged_posts.push((post_id, post, vis, intent, blobs));
|
|
}
|
|
|
|
// follows.json (optional)
|
|
let follows: Vec<NodeId> = match archive.by_name("itsgoin-export/follows.json") {
|
|
Ok(mut entry) => {
|
|
let mut buf = String::new();
|
|
entry.read_to_string(&mut buf)?;
|
|
serde_json::from_str::<Vec<String>>(&buf)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|s| {
|
|
let b = hex::decode(&s).ok()?;
|
|
<[u8; 32]>::try_from(b.as_slice()).ok()
|
|
})
|
|
.collect()
|
|
}
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
// profiles.json (optional)
|
|
let profiles: Vec<crate::types::PublicProfile> = match archive.by_name("itsgoin-export/profiles.json") {
|
|
Ok(mut entry) => {
|
|
let mut buf = String::new();
|
|
entry.read_to_string(&mut buf)?;
|
|
serde_json::from_str(&buf).unwrap_or_default()
|
|
}
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
Ok(StagedImport {
|
|
posting_identities,
|
|
posts: staged_posts,
|
|
follows,
|
|
profiles,
|
|
})
|
|
}).await??
|
|
};
|
|
|
|
// Phase 2: write into storage.
|
|
let mut imported_posts = 0usize;
|
|
let mut imported_blobs = 0usize;
|
|
let mut imported_personas = 0usize;
|
|
let mut skipped_posts = 0usize;
|
|
|
|
// Posting identities first so the decrypt-any-persona path (feed render)
|
|
// can find them immediately after this import call returns. If the
|
|
// current device has exactly one posting identity (typically the one
|
|
// auto-created on first launch) and we're importing additional ones,
|
|
// switch the default to the first imported persona — the user's intent is
|
|
// to pick up where they left off, not to post under a fresh throwaway.
|
|
{
|
|
let s = storage.get().await;
|
|
let prior_count = s.count_posting_identities().unwrap_or(0);
|
|
for pi in &staged.posting_identities {
|
|
s.upsert_posting_identity(pi)?;
|
|
imported_personas += 1;
|
|
}
|
|
if prior_count <= 1 {
|
|
if let Some(first) = staged.posting_identities.first() {
|
|
let _ = s.set_default_posting_id(&first.node_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Posts + blobs. Content keeps its original post_id, author, signatures.
|
|
for (post_id, post, vis, intent, blobs) in &staged.posts {
|
|
let s = storage.get().await;
|
|
if s.get_post(post_id).ok().flatten().is_some() {
|
|
skipped_posts += 1;
|
|
continue;
|
|
}
|
|
if crate::content::verify_post_id(post_id, post) {
|
|
// Intent was parsed from the export (or heuristic-inferred when
|
|
// the export predates intent storage). See `parse_exported_intent`.
|
|
s.store_post_with_intent(post_id, post, vis, intent)?;
|
|
imported_posts += 1;
|
|
} else {
|
|
warn!(post_id = hex::encode(post_id), "Skipping post with invalid signature during import");
|
|
skipped_posts += 1;
|
|
continue;
|
|
}
|
|
for (att, data) in blobs {
|
|
if !blob_store.has(&att.cid) {
|
|
blob_store.store(&att.cid, data)?;
|
|
}
|
|
s.record_blob(&att.cid, post_id, &post.author, data.len() as u64, &att.mime_type, post.timestamp_ms)?;
|
|
let _ = s.pin_blob(&att.cid);
|
|
imported_blobs += 1;
|
|
}
|
|
}
|
|
|
|
// Follows + profiles.
|
|
{
|
|
let s = storage.get().await;
|
|
for f in &staged.follows {
|
|
let _ = s.add_follow(f);
|
|
}
|
|
for p in &staged.profiles {
|
|
let _ = s.store_profile(p);
|
|
}
|
|
}
|
|
|
|
Ok(ImportResult {
|
|
posts_imported: imported_posts,
|
|
posts_skipped: skipped_posts,
|
|
blobs_imported: imported_blobs,
|
|
message: format!(
|
|
"Imported {} personas, {} posts ({} skipped), {} blobs",
|
|
imported_personas, imported_posts, skipped_posts, imported_blobs
|
|
),
|
|
})
|
|
}
|
|
|
|
/// Import public posts from a ZIP into the current identity.
|
|
/// Creates new posts with the current node_id as author, preserving original timestamps.
|
|
pub async fn import_public_posts(
|
|
zip_path: &Path,
|
|
storage: &StoragePool,
|
|
blob_store: &BlobStore,
|
|
our_node_id: &NodeId,
|
|
) -> anyhow::Result<ImportResult> {
|
|
// Phase 1: Read everything from ZIP synchronously (no Send requirement)
|
|
let parsed = {
|
|
let zip_path = zip_path.to_path_buf();
|
|
let our_node_id = *our_node_id;
|
|
tokio::task::spawn_blocking(move || -> anyhow::Result<ParsedImport> {
|
|
let file = std::fs::File::open(&zip_path)?;
|
|
let mut archive = zip::ZipArchive::new(file)?;
|
|
|
|
let posts: Vec<ExportedPost> = {
|
|
let mut entry = archive.by_name("itsgoin-export/posts.json")?;
|
|
let mut buf = String::new();
|
|
entry.read_to_string(&mut buf)?;
|
|
serde_json::from_str(&buf)?
|
|
};
|
|
|
|
let mut result_posts = Vec::new();
|
|
let mut skipped = 0usize;
|
|
|
|
for ep in &posts {
|
|
let vis: PostVisibility = serde_json::from_str(&ep.visibility_json).unwrap_or(PostVisibility::Public);
|
|
if !matches!(vis, PostVisibility::Public) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
let attachments: Vec<Attachment> = serde_json::from_str(&ep.attachments_json).unwrap_or_default();
|
|
let new_post = Post {
|
|
author: our_node_id,
|
|
content: ep.content.clone(),
|
|
attachments: attachments.clone(),
|
|
timestamp_ms: ep.timestamp_ms,
|
|
fof_gating: None,
|
|
};
|
|
|
|
// Read blob data from archive
|
|
let mut blob_data = Vec::new();
|
|
for att in &attachments {
|
|
let cid_hex = hex::encode(att.cid);
|
|
let blob_path = format!("itsgoin-export/blobs/{}", cid_hex);
|
|
if let Ok(mut blob_entry) = archive.by_name(&blob_path) {
|
|
let mut data = Vec::new();
|
|
blob_entry.read_to_end(&mut data)?;
|
|
blob_data.push((att.clone(), data));
|
|
}
|
|
}
|
|
|
|
result_posts.push((new_post, vis, blob_data));
|
|
}
|
|
|
|
Ok(ParsedImport { posts: result_posts, skipped })
|
|
}).await??
|
|
};
|
|
|
|
// Phase 2: Store to DB + blob store (async — needs storage.get().await)
|
|
let mut imported = 0usize;
|
|
let mut blobs_imported = 0usize;
|
|
|
|
info!(post_count = parsed.posts.len(), skipped = parsed.skipped, "Import phase 2: storing to DB");
|
|
|
|
// Ensure we follow ourselves so imported posts appear in feed
|
|
{
|
|
let s = storage.get().await;
|
|
let _ = s.add_follow(our_node_id);
|
|
}
|
|
|
|
let now = now_ms();
|
|
|
|
for (new_post, _vis, blob_data) in &parsed.posts {
|
|
let new_id = compute_post_id(new_post);
|
|
|
|
let s = storage.get().await;
|
|
if s.get_post(&new_id).ok().flatten().is_some() {
|
|
drop(s);
|
|
debug!(post = hex::encode(new_id), "Import: skipping duplicate post");
|
|
continue;
|
|
}
|
|
// Store post with intent (matches create_post_with_visibility behavior)
|
|
s.store_post_with_intent(&new_id, new_post, &PostVisibility::Public, &crate::types::VisibilityIntent::Public)?;
|
|
|
|
// Store blobs + record them, matching normal post creation
|
|
for (att, data) in blob_data {
|
|
if !blob_store.has(&att.cid) {
|
|
blob_store.store(&att.cid, data)?;
|
|
}
|
|
s.record_blob(&att.cid, &new_id, our_node_id, data.len() as u64, &att.mime_type, att.size_bytes)?;
|
|
let _ = s.pin_blob(&att.cid);
|
|
blobs_imported += 1;
|
|
}
|
|
|
|
// Create BlobHeader (matches what engagement/sync expects)
|
|
let header = crate::types::BlobHeader {
|
|
post_id: new_id,
|
|
author: *our_node_id,
|
|
reactions: vec![],
|
|
comments: vec![],
|
|
policy: crate::types::CommentPolicy::default(),
|
|
updated_at: now,
|
|
thread_splits: vec![],
|
|
receipt_slots: vec![],
|
|
comment_slots: vec![],
|
|
prior_author: None,
|
|
};
|
|
let header_json = serde_json::to_string(&header).unwrap_or_default();
|
|
let _ = s.store_blob_header(&new_id, our_node_id, &header_json, now);
|
|
drop(s);
|
|
|
|
imported += 1;
|
|
debug!(imported, post = hex::encode(new_id), "Import: stored post");
|
|
}
|
|
|
|
info!(imported, skipped = parsed.skipped, blobs = blobs_imported, "Public post import complete");
|
|
|
|
Ok(ImportResult {
|
|
posts_imported: imported,
|
|
posts_skipped: parsed.skipped,
|
|
blobs_imported,
|
|
message: format!("Imported {} posts ({} skipped), {} blobs", imported, parsed.skipped, blobs_imported),
|
|
})
|
|
}
|
|
|
|
/// Import a ZIP as a new identity (create identity subdir, extract everything).
|
|
pub fn import_as_identity(
|
|
zip_path: &Path,
|
|
base_dir: &Path,
|
|
) -> anyhow::Result<String> {
|
|
let file = std::fs::File::open(zip_path)?;
|
|
let mut archive = zip::ZipArchive::new(file)?;
|
|
|
|
// Read manifest
|
|
let manifest: ExportManifest = {
|
|
let mut entry = archive.by_name("itsgoin-export/manifest.json")?;
|
|
let mut buf = String::new();
|
|
entry.read_to_string(&mut buf)?;
|
|
serde_json::from_str(&buf)?
|
|
};
|
|
|
|
// Read identity key
|
|
let key_data = {
|
|
let mut entry = archive.by_name("itsgoin-export/identity.key")
|
|
.map_err(|_| anyhow::anyhow!("Export doesn't contain an identity key"))?;
|
|
let mut buf = Vec::new();
|
|
entry.read_to_end(&mut buf)?;
|
|
buf
|
|
};
|
|
|
|
// Create identity directory
|
|
let id_dir = base_dir.join("identities").join(&manifest.node_id);
|
|
if id_dir.exists() {
|
|
anyhow::bail!("Identity {} already exists", &manifest.node_id[..12]);
|
|
}
|
|
std::fs::create_dir_all(&id_dir)?;
|
|
|
|
// Write identity key
|
|
let key_path = id_dir.join("identity.key");
|
|
std::fs::write(&key_path, &key_data)?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600));
|
|
}
|
|
|
|
// Write metadata
|
|
let now = now_ms();
|
|
let meta = serde_json::json!({
|
|
"display_name": format!("Imported {}", &manifest.node_id[..12]),
|
|
"created_at": now,
|
|
"last_used_at": now,
|
|
});
|
|
std::fs::write(id_dir.join("meta.json"), serde_json::to_string_pretty(&meta)?)?;
|
|
|
|
info!(identity = manifest.node_id, "Imported identity from ZIP — switch to it to restore data");
|
|
|
|
// Note: posts, blobs, follows, settings will be restored when the user switches to this
|
|
// identity and opens the node. The full DB restore could be done here, but it's simpler
|
|
// to let the user switch and then import posts via the import wizard.
|
|
|
|
Ok(manifest.node_id)
|
|
}
|
|
|
|
/// Merge posts from another identity into the current one using the original key for decryption.
|
|
/// Decrypts encrypted posts, creates new posts under the current identity, preserves timestamps.
|
|
/// BlobHeader gets `prior_author` set for provenance.
|
|
pub async fn merge_with_key(
|
|
zip_path: &Path,
|
|
original_key_hex: &str,
|
|
storage: &StoragePool,
|
|
blob_store: &BlobStore,
|
|
our_node_id: &NodeId,
|
|
_our_seed: &[u8; 32],
|
|
) -> anyhow::Result<ImportResult> {
|
|
// Derive the original identity from the provided key
|
|
let original_seed_bytes = hex::decode(original_key_hex)?;
|
|
let original_seed: [u8; 32] = original_seed_bytes.try_into()
|
|
.map_err(|_| anyhow::anyhow!("key must be 32 bytes (64 hex chars)"))?;
|
|
let original_secret_key = iroh::SecretKey::from_bytes(&original_seed);
|
|
let original_node_id: NodeId = *original_secret_key.public().as_bytes();
|
|
|
|
// Phase 1: Read and decrypt everything from ZIP synchronously
|
|
let parsed = {
|
|
let zip_path = zip_path.to_path_buf();
|
|
let our_nid = *our_node_id;
|
|
let orig_seed = original_seed;
|
|
let orig_nid = original_node_id;
|
|
|
|
tokio::task::spawn_blocking(move || -> anyhow::Result<ParsedImport> {
|
|
let file = std::fs::File::open(&zip_path)?;
|
|
let mut archive = zip::ZipArchive::new(file)?;
|
|
|
|
let posts: Vec<ExportedPost> = {
|
|
let mut entry = archive.by_name("itsgoin-export/posts.json")?;
|
|
let mut buf = String::new();
|
|
entry.read_to_string(&mut buf)?;
|
|
serde_json::from_str(&buf)?
|
|
};
|
|
|
|
let mut result_posts = Vec::new();
|
|
let mut skipped = 0usize;
|
|
|
|
for ep in &posts {
|
|
let vis: PostVisibility = serde_json::from_str(&ep.visibility_json)
|
|
.unwrap_or(PostVisibility::Public);
|
|
let attachments: Vec<Attachment> = serde_json::from_str(&ep.attachments_json)
|
|
.unwrap_or_default();
|
|
|
|
// Decrypt content if encrypted
|
|
let plaintext = match &vis {
|
|
PostVisibility::Public => ep.content.clone(),
|
|
PostVisibility::Encrypted { recipients } => {
|
|
match crate::crypto::decrypt_post(
|
|
&ep.content, &orig_seed, &orig_nid, &orig_nid, recipients,
|
|
) {
|
|
Ok(Some(text)) => text,
|
|
Ok(None) => {
|
|
debug!(post = ep.id, "Not a recipient of this post — skipping");
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
Err(e) => {
|
|
warn!(post = ep.id, error = %e, "Failed to decrypt post — skipping");
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
PostVisibility::GroupEncrypted { .. } => {
|
|
// Group decryption needs the group seed — skip for now
|
|
debug!(post = ep.id, "Group-encrypted post — skipping (group merge not yet supported)");
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Create new post under our identity
|
|
let new_post = Post {
|
|
author: our_nid,
|
|
content: plaintext,
|
|
attachments: attachments.clone(),
|
|
timestamp_ms: ep.timestamp_ms,
|
|
fof_gating: None,
|
|
};
|
|
|
|
// Read blob data from archive (may need decryption for encrypted posts)
|
|
let mut blob_data = Vec::new();
|
|
for att in &attachments {
|
|
let cid_hex = hex::encode(att.cid);
|
|
let blob_path = format!("itsgoin-export/blobs/{}", cid_hex);
|
|
if let Ok(mut blob_entry) = archive.by_name(&blob_path) {
|
|
let mut data = Vec::new();
|
|
blob_entry.read_to_end(&mut data)?;
|
|
|
|
// If the post was encrypted, blobs are also encrypted with the same CEK
|
|
if matches!(vis, PostVisibility::Encrypted { .. }) {
|
|
if let PostVisibility::Encrypted { ref recipients } = vis {
|
|
if let Ok(Some(cek)) = crate::crypto::unwrap_cek_for_recipient(
|
|
&orig_seed, &orig_nid, &orig_nid, recipients,
|
|
) {
|
|
if let Ok(decrypted) = crate::crypto::decrypt_bytes_with_cek(&data, &cek) {
|
|
data = decrypted;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
blob_data.push((att.clone(), data));
|
|
}
|
|
}
|
|
|
|
// Merged posts go in as public (decrypted content, new author)
|
|
result_posts.push((new_post, PostVisibility::Public, blob_data));
|
|
}
|
|
|
|
Ok(ParsedImport { posts: result_posts, skipped })
|
|
}).await??
|
|
};
|
|
|
|
// Phase 2: Store with prior_author provenance
|
|
let mut imported = 0usize;
|
|
let mut blobs_imported = 0usize;
|
|
|
|
for (new_post, _vis, blob_data) in &parsed.posts {
|
|
let new_id = compute_post_id(new_post);
|
|
|
|
let s = storage.get().await;
|
|
if s.get_post(&new_id).ok().flatten().is_some() {
|
|
continue;
|
|
}
|
|
s.store_post_with_visibility(&new_id, new_post, &PostVisibility::Public)?;
|
|
|
|
// Create BlobHeader with prior_author
|
|
let now = now_ms();
|
|
let header = crate::types::BlobHeader {
|
|
post_id: new_id,
|
|
author: *our_node_id,
|
|
reactions: vec![],
|
|
comments: vec![],
|
|
policy: crate::types::CommentPolicy::default(),
|
|
updated_at: now,
|
|
thread_splits: vec![],
|
|
receipt_slots: vec![],
|
|
comment_slots: vec![],
|
|
prior_author: Some(original_node_id),
|
|
};
|
|
let header_json = serde_json::to_string(&header).unwrap_or_default();
|
|
let _ = s.store_blob_header(&new_id, our_node_id, &header_json, now);
|
|
drop(s);
|
|
|
|
for (att, data) in blob_data {
|
|
if !blob_store.has(&att.cid) {
|
|
blob_store.store(&att.cid, data)?;
|
|
let s = storage.get().await;
|
|
let _ = s.record_blob(&att.cid, &new_id, our_node_id, data.len() as u64, &att.mime_type, att.size_bytes);
|
|
blobs_imported += 1;
|
|
}
|
|
}
|
|
|
|
imported += 1;
|
|
}
|
|
|
|
info!(
|
|
imported, skipped = parsed.skipped, blobs = blobs_imported,
|
|
original = hex::encode(original_node_id),
|
|
"Merge with key complete"
|
|
);
|
|
|
|
Ok(ImportResult {
|
|
posts_imported: imported,
|
|
posts_skipped: parsed.skipped,
|
|
blobs_imported,
|
|
message: format!(
|
|
"Merged {} posts from {} ({} skipped), {} blobs",
|
|
imported, &hex::encode(original_node_id)[..12], parsed.skipped, blobs_imported
|
|
),
|
|
})
|
|
}
|
|
|
|
fn now_ms() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64
|
|
}
|