use knot_cob::{CobError, CobStore, WriteContext};
use knot_cobs::{Editing, Manifest, ManifestChange, Opening, SocialChange, SocialObject};
use knot_record::manifest::{MANIFEST_RKEY, ManifestMeta, ManifestRecord, manifest_collection};
use knot_types::{
AccountDid, CobId, ContributionPolicy, OwnerDid, RecordAddress, RecordBody, RecordRkey,
RepoName, RepoRkey,
};
use super::drafts::{ManifestDraft, StoredManifest, SwapInput, kind_matches, typed};
use super::error::WriteError;
use super::ledger::{collection_type, ready};
use super::session::wrote;
use super::{Ledger, SETTINGS_DENIED, Written};
impl Ledger<'_> {
pub(super) fn manifest_address() -> RecordAddress {
RecordAddress::new(
manifest_collection(),
RecordRkey::new(MANIFEST_RKEY).expect("Manifest rkey is a valid record key"),
)
}
pub(super) fn stored_manifest(
&self,
object: CobId,
live: &SocialObject,
) -> Result<RecordBody, WriteError> {
let version = live
.version()
.ok_or_else(|| WriteError::internal("A live manifest is missing its version"))?;
CobStore::new(self.git)
.changes_of(
&collection_type(&manifest_collection()),
object,
knot_cob::HistoryModel::Convergent,
None,
)?
.changes
.into_iter()
.rev()
.filter_map(|change| change.record.into_body())
.find(|body| body.cid() == version)
.ok_or_else(|| WriteError::internal("A live manifest is missing its record bytes"))
}
pub(super) fn manifest_put(
&self,
cx: &WriteContext<'_>,
swap: SwapInput,
record: serde_json::Value,
) -> Result<Written, WriteError> {
let (_, rkey, from) = swap.edit_of(&manifest_collection())?;
if rkey.as_str() != MANIFEST_RKEY {
return Err(WriteError::invalid_request(format!(
"the manifest's record key is `{MANIFEST_RKEY}`, and this knot won't write it as \
{rkey}",
)));
}
let collection = manifest_collection();
let draft = typed::<ManifestDraft>(record, &collection)?;
kind_matches(draft.kind.as_ref(), &collection)?;
Opening::Ownership
.decide(cx.permission())
.is_allowed()
.then_some(())
.ok_or_else(|| WriteError::forbidden(SETTINGS_DENIED))?;
let address = Self::manifest_address();
self.ensure_manifest(cx, &address)?;
let (object, live) = self.standing(&address)?;
let stored: StoredManifest =
serde_ipld_dagcbor::from_slice(self.stored_manifest(object, &live)?.as_bytes())
.map_err(|error| {
WriteError::internal(format!("The manifest record doesn't parse: {error}"))
})?;
if stored.declaration.owner != draft.declaration.owner {
return Err(WriteError::invalid_request(format!(
"the repository's owner is set at creation: the manifest declares {}, and the \
edit declares {}",
stored.declaration.owner, draft.declaration.owner,
)));
}
let rename = if stored.declaration.name != draft.declaration.name {
let name = draft.declaration.name.clone();
let rkey = RepoRkey::new(name.as_str().to_lowercase())
.map_err(|error| WriteError::invalid_request(error.to_string()))?;
match ready(
self.index.resolve_repo(&stored.declaration.owner, &rkey),
"registry",
)? {
Some(existing) if existing != *self.repo => {
return Err(WriteError::conflict(format!(
"the record key {rkey} already belongs to another repository"
)));
}
_ => Some((rkey, name)),
}
} else {
None
};
let policy = match (stored.contribution_policy, draft.contribution_policy) {
(Some(_), None) => {
return Err(WriteError::invalid_request(
"clearing the manifest's contributionPolicy isn't supported; set it to a policy",
));
}
(old, Some(new)) if old != Some(new) => Some(new),
_ => None,
};
let record = ManifestRecord::new(
stored.declaration.owner.clone(),
draft.declaration.name.clone(),
ManifestMeta {
spindle: draft.spindle,
description: draft.description,
website: draft.website,
topics: draft.topics,
source: draft.source,
contribution_policy: draft.contribution_policy,
},
live.created_at(),
cx.actor().clone(),
)?
.body()?;
let produces = record.cid();
if live.version() == Some(produces) && from == produces {
return Ok(wrote(self.repo, &address, produces));
}
if let Some(current) = live.version()
&& from != current
{
let uri = address.at_uri(self.repo);
return Err(CobError::StaleRecord {
object,
uri,
current,
}
.into());
}
if let Some((rkey, name)) = rename {
self.rename_in_registry(stored.declaration.owner.clone(), rkey, name)?;
}
if let Some(policy) = policy {
self.set_policy(stored.declaration.owner, policy)?;
}
let change: ManifestChange = SocialChange::Edit {
editing: Editing {
author: cx.actor().clone(),
from,
produces,
edited_at: self.now,
},
body: Manifest,
};
let written = self.edit(cx, &address, change, object, &record)?;
self.index.refresh_registry().map_err(|error| {
WriteError::internal(format!(
"repo {} took new settings but registry projection refresh failed: {error}",
self.repo
))
})?;
Ok(written)
}
pub(super) fn ensure_manifest(
&self,
cx: &WriteContext<'_>,
address: &RecordAddress,
) -> Result<(), WriteError> {
if ready(self.index.social_object_at(self.repo, address), "social")?.is_some() {
return Ok(());
}
Opening::Ownership
.decide(cx.permission())
.is_allowed()
.then_some(())
.ok_or_else(|| WriteError::forbidden(SETTINGS_DENIED))?;
let ownership = ready(self.index.ownership_of(self.repo), "registry")?
.ok_or_else(|| WriteError::not_found("No such repository on this knot"))?;
let registry = crate::registry::load_state(self.meta)
.map_err(|error| WriteError::internal(error.to_string()))?;
let name = registry
.as_ref()
.and_then(|reg| reg.record_of(self.repo))
.map(|rec| rec.name.clone())
.map(Ok)
.unwrap_or_else(|| {
RepoName::new(ownership.rkey.as_str())
.map_err(|error| WriteError::internal(error.to_string()))
})?;
let created_at = crate::atproto::created_at(self.chain_root, self.repo).unwrap_or(self.now);
let editor = AccountDid::new(ownership.owner.as_str())
.map_err(|error| WriteError::internal(error.to_string()))?;
let record = ManifestRecord::new(
ownership.owner,
name,
ManifestMeta::default(),
created_at,
editor,
)?
.body()?;
let number = self.next_number(&manifest_collection())?;
self.open_at(cx, address, number, None, &record, Manifest)?;
Ok(())
}
fn rename_in_registry(
&self,
owner: OwnerDid,
rkey: RepoRkey,
name: RepoName,
) -> Result<(), WriteError> {
let store = CobStore::new(self.meta);
let registries = store.list::<knot_cobs::RepoRegistryCob>()?;
let [object] = registries.as_slice() else {
return Err(WriteError::internal(
"the meta repo lacks one repo registry",
));
};
knot_cobs::rename_repo(
&store,
self.home,
*object,
knot_cobs::Rename {
owner,
rkey,
name,
repo: self.repo.clone(),
},
self.signer,
self.now,
)
.map(|_| ())
.map_err(WriteError::from)
}
fn set_policy(&self, owner: OwnerDid, policy: ContributionPolicy) -> Result<(), WriteError> {
let store = CobStore::new(self.meta);
let registries = store.list::<knot_cobs::RepoRegistryCob>()?;
let [object] = registries.as_slice() else {
return Err(WriteError::internal(
"the meta repo lacks one repo registry",
));
};
knot_cobs::set_repo_policy(
&store,
self.home,
*object,
knot_cobs::Policy {
owner,
repo: self.repo.clone(),
policy,
},
self.signer,
self.now,
)
.map(|_| ())
.map_err(WriteError::from)
}
}
#[derive(Default)]
pub struct Settings {
pub name: Option<RepoName>,
pub description: Option<String>,
pub website: Option<String>,
pub topics: Option<Vec<String>>,
pub policy: Option<ContributionPolicy>,
/// `Some(None)` removes the spindle, `None` leaves it alone.
pub spindle: Option<Option<knot_types::ServiceDid>>,
pub source: Option<knot_record::manifest::ManifestSource>,
}
impl Settings {
pub(super) fn apply(&self, record: &mut serde_json::Value) {
if let Some(name) = &self.name {
record["declaration"]["name"] = name.as_str().into();
}
if let Some(description) = &self.description {
record["description"] = description.clone().into();
}
if let Some(website) = &self.website {
record["website"] = website.clone().into();
}
if let Some(topics) = &self.topics {
record["topics"] = topics.clone().into();
}
if let Some(policy) = self.policy {
record["contributionPolicy"] = serde_json::json!(policy);
}
if let Some(source) = &self.source {
record["source"] = serde_json::json!(source);
}
match &self.spindle {
Some(Some(spindle)) => record["spindle"] = serde_json::json!(spindle),
Some(None) => {
if let Some(fields) = record.as_object_mut() {
fields.remove("spindle");
}
}
None => {}
}
}
}