//! lifecycle xrpcs skip cob ledger routing because they mint, drop or repoint whole repos.
use std::sync::Arc;
use knot_git::{GitError, RefUpdate};
use knot_index::{Index, Resolved};
use knot_record::git_refs;
use knot_record::manifest::{ManifestSource, RepoDescription, RepoTopics};
use knot_runtime::Signer as _;
use knot_types::{
AccountDid, ActorId, BranchName, ContributionPolicy, OwnerDid, RecordAddress, RecordRkey,
RepoDid, RepoName, RepoRkey, ServiceDid,
};
use serde::Deserialize;
use crate::cgi::{Request, authenticate_addressed};
use crate::cob_write::{Settings, WriteError};
use crate::config::ValidatedConfig;
use crate::repolock::{self, LockMode};
use crate::writes::{self, Actor};
pub const CREATE_REPO: &str = "org.tangled.repo.createRepo";
pub const DELETE_REPO: &str = "org.tangled.repo.deleteRepo";
pub const SET_DEFAULT_BRANCH: &str = "org.tangled.git.setDefaultBranch";
pub const DELETE_BRANCH: &str = "org.tangled.git.deleteBranch";
pub const TEMP_DELETE_BRANCH: &str = "org.tangled.temp.git.deleteBranch";
pub const ALL: [&str; 5] = [
CREATE_REPO,
DELETE_REPO,
SET_DEFAULT_BRANCH,
DELETE_BRANCH,
TEMP_DELETE_BRANCH,
];
const CREATE_DENIED: &str = "only knot owner or hosted repo owners may create repositories";
const DELETE_DENIED: &str = "only repository owner or the knot owner may delete it";
const SETTINGS_DENIED: &str =
"only repository owner or the knot owner may change its default branch";
const BRANCH_DENIED: &str = "only repository owner or a collaborator may change its branches";
/// held through the name check and the create, or two creates can take one name.
const CREATE_LOCK: &str = "create-repo";
#[derive(Deserialize)]
struct CreateInput {
name: RepoName,
#[serde(rename = "defaultBranch")]
default_branch: Option<BranchName>,
spindle: Option<ServiceDid>,
description: Option<RepoDescription>,
website: Option<url::Url>,
topics: Option<RepoTopics>,
source: Option<ManifestSource>,
}
#[derive(Deserialize)]
struct DeleteInput {
repo: RepoDid,
#[serde(default)]
force: bool,
}
#[derive(Deserialize)]
struct BranchInput {
repo: RepoDid,
branch: BranchName,
}
fn internal(error: impl std::fmt::Display) -> WriteError {
WriteError::internal(format!("{error:#}"))
}
fn decode<T: serde::de::DeserializeOwned>(body: &[u8]) -> Result<T, WriteError> {
serde_json::from_slice(body).map_err(|error| WriteError::invalid_request(error.to_string()))
}
fn ready<T>(resolved: Resolved<T>) -> Result<T, WriteError> {
match resolved {
Resolved::Ready(value) => Ok(value),
Resolved::Warming => Err(WriteError::warming(
"registry projection is still warming, please retry shortly",
)),
}
}
pub fn route(request: &Request) -> Option<&'static str> {
let nsid = request.path().strip_prefix("/xrpc/")?;
ALL.into_iter().find(|known| *known == nsid)
}
pub async fn serve(
config: &Arc<ValidatedConfig>,
request: &Request,
nsid: &str,
body: &[u8],
) -> Result<serde_json::Value, WriteError> {
match nsid {
CREATE_REPO => create(config, request, body).await,
DELETE_REPO => delete(config, request, body).await,
SET_DEFAULT_BRANCH => set_default_branch(config, request, body).await,
_ => delete_branch(config, request, body).await,
}
}
fn may_create(config: &ValidatedConfig, index: &Index, actor: &AccountDid) -> bool {
let owner = OwnerDid::from(actor.clone());
*actor == config.owner
|| index.hosted_repos().iter().any(
|repo| matches!(index.owner_of(repo), Resolved::Ready(Some(held)) if held == owner),
)
}
async fn create(
config: &Arc<ValidatedConfig>,
request: &Request,
body: &[u8],
) -> Result<serde_json::Value, WriteError> {
let actor = authenticate_addressed(config, request, CREATE_REPO, None).await?;
let input: CreateInput = decode(body)?;
let rkey = RepoRkey::from_repo_name(&input.name)
.map_err(|error| WriteError::invalid_request(error.to_string()))?;
let source = match &input.source {
Some(ManifestSource::Tangled { did }) => Some(did.clone()),
Some(ManifestSource::External { .. }) => {
return Err(WriteError::invalid_request(
"this knot forks tangled repos and doesn't import external ones",
));
}
None => None,
};
let owner = OwnerDid::from(actor.clone());
let created = repolock::acquire(&config.output_root, CREATE_LOCK, LockMode::Exclusive)
.map_err(internal)?;
let (.., index) = writes::rebuilt_index(config)?;
if !may_create(config, &index, &actor) {
return Err(WriteError::forbidden(CREATE_DENIED));
}
if ready(index.resolve_repo(&owner, &rkey))?.is_some() {
return Err(WriteError::conflict(
"repository with that record key already exists for this owner",
));
}
let seed = match &source {
Some(source) => Some(crate::fork::seed(config, source).await?),
None => None,
};
let repo = crate::provision::create_for_xrpc(
config.clone(),
&owner,
&input.name,
&rkey,
input
.default_branch
.as_ref()
.unwrap_or(&config.default_branch),
source.as_ref().map(|did| did.to_string()),
)
.await
.map_err(internal)?;
if let Some(seed) = seed
&& let Err(error) = crate::fork::plant(config, &repo, seed)
{
if let Err(cleanup) = writes::retire(config, &repo) {
tracing::error!(%repo, "a fork that wouldn't seed stays registered: {cleanup}");
}
return Err(error);
}
drop(created);
let settings = Settings {
description: input.description.map(|text| text.as_str().to_owned()),
website: input.website.map(String::from),
topics: input.topics.map(|topics| {
topics
.as_slice()
.iter()
.map(|topic| topic.as_str().to_owned())
.collect()
}),
policy: Some(ContributionPolicy::Anyone),
spindle: input.spindle.map(Some),
source: input.source,
..Settings::default()
};
let published =
writes::perform_with(config, &repo, Actor::Account(actor), |ledger, session| {
ledger.settings(session, &settings)
})
.await?;
crate::owner::announce(config, &published).await;
let signer = crate::identity::load_knot_signer(config).map_err(internal)?;
let key = ActorId::from_secp256k1(signer.public_key().as_bytes());
Ok(serde_json::json!({ "did": repo, "key": key }))
}
/// check the pds first, a live declaration would dangle once the repo is gone.
async fn delete(
config: &Arc<ValidatedConfig>,
request: &Request,
body: &[u8],
) -> Result<serde_json::Value, WriteError> {
let input: DeleteInput = decode(body)?;
let actor = authenticate_addressed(config, request, DELETE_REPO, Some(&input.repo)).await?;
let (.., index) = writes::rebuilt_index(config)?;
let Some(target) = ready(index.ownership_of(&input.repo))? else {
return Ok(serde_json::json!({}));
};
let owner = AccountDid::from(target.owner.clone());
if actor != owner && actor != config.owner {
return Err(WriteError::forbidden(DELETE_DENIED));
}
if input.force && actor != config.owner {
return Err(WriteError::forbidden(
"only the knot owner may force a delete past the PDS record check",
));
}
if !input.force {
let atproto = crate::cgi::atproto(config)?;
match atproto.repo_record_present(&owner, &target.rkey).await {
Ok(knot_atproto::RecordPresence::Present) => {
return Err(WriteError::conflict(
"org.tangled.repo.declaration record still exists on the owner's PDS. Remove \
it there first or force the delete.",
));
}
Ok(knot_atproto::RecordPresence::Absent) => {}
Err(error) => {
tracing::warn!(repo = %input.repo, "deleting without the owner's PDS answering: {error}");
}
}
}
writes::retire(config, &input.repo)?;
let changed = writes::publish_scoped(config, None).await.map_err(|error| {
tracing::error!(repo = %input.repo, "the deleted repo wasn't unpublished: {error:#}");
WriteError::internal_notice(
"the knot deleted this repo but couldn't publish that yet. it drops out once the knot \
publishes again",
)
})?;
if !changed.is_empty() {
crate::crawl::announce(config, &changed).await;
}
Ok(serde_json::json!({}))
}
fn owner_of(index: &Index, repo: &RepoDid) -> Result<AccountDid, WriteError> {
ready(index.owner_of(repo))?
.map(AccountDid::from)
.ok_or_else(|| WriteError::not_found("No such repository on this knot"))
}
async fn set_default_branch(
config: &Arc<ValidatedConfig>,
request: &Request,
body: &[u8],
) -> Result<serde_json::Value, WriteError> {
let input: BranchInput = decode(body)?;
let actor =
authenticate_addressed(config, request, SET_DEFAULT_BRANCH, Some(&input.repo)).await?;
let (layout, _, index) = writes::rebuilt_index(config)?;
let owner = owner_of(&index, &input.repo)?;
if actor != owner && actor != config.owner {
return Err(WriteError::forbidden(SETTINGS_DENIED));
}
let git = layout.open(&input.repo)?;
let refname = input.branch.head_ref();
{
let _lock = repo_lock(config, &input.repo)?;
if git.find_ref(&refname)?.is_none() && !git.branches()?.is_empty() {
return Err(WriteError::not_found("no such branch to set as default"));
}
git.set_head(&input.branch)?;
}
republish(config, &input.repo).await?;
let rkey = RecordRkey::new(git_refs::SELF_RKEY).expect("the refs self key is a valid rkey");
let cid = git_refs::desired_record(&git)
.map_err(internal)?
.into_values()
.next()
.ok_or_else(|| WriteError::internal("the refs record went missing after the move"))?
.cid();
Ok(serde_json::json!({
"uri": RecordAddress::new(git_refs::git_refs_collection(), rkey).at_uri(&input.repo).as_str(),
"cid": cid.to_string(),
}))
}
async fn delete_branch(
config: &Arc<ValidatedConfig>,
request: &Request,
body: &[u8],
) -> Result<serde_json::Value, WriteError> {
let input: BranchInput = decode(body)?;
let actor =
match authenticate_addressed(config, request, DELETE_BRANCH, Some(&input.repo)).await {
Ok(actor) => actor,
Err(_) => {
match authenticate_addressed(config, request, TEMP_DELETE_BRANCH, Some(&input.repo))
.await
{
Ok(actor) => actor,
Err(_) => {
authenticate_addressed(
config,
request,
"org.tangled.git.receivePack",
Some(&input.repo),
)
.await?
}
}
}
};
let (layout, _, index) = writes::rebuilt_index(config)?;
owner_of(&index, &input.repo)?;
if !crate::git_ssh::may_push(config, &index, std::slice::from_ref(&actor), &input.repo)? {
return Err(WriteError::forbidden(BRANCH_DENIED));
}
let git = layout.open(&input.repo)?;
let refname = input.branch.head_ref();
{
let _lock = repo_lock(config, &input.repo)?;
if git.default_branch().ok().as_ref() == Some(&input.branch) {
return Err(WriteError::invalid_request(
"default branch cannot be deleted until a different default is set",
));
}
let old = git
.find_ref(&refname)?
.ok_or_else(|| WriteError::not_found("no such branch"))?;
git.update_ref(&RefUpdate::Delete { name: refname, old })
.map_err(|error| match error {
GitError::AtomicRefs(_) => {
WriteError::conflict("branch changed during deletion, retry")
}
other => other.into(),
})?;
crate::commit_graph::refresh(git.path());
}
republish(config, &input.repo).await?;
Ok(serde_json::json!({}))
}
fn repo_lock(config: &ValidatedConfig, repo: &RepoDid) -> Result<repolock::RepoLock, WriteError> {
repolock::acquire(
&config.output_root,
&crate::plan::repo_key(repo.as_str()),
LockMode::Exclusive,
)
.map_err(internal)
}
async fn republish(config: &Arc<ValidatedConfig>, repo: &RepoDid) -> Result<(), WriteError> {
let changed = writes::publish_stored(config, repo, None).await?;
if !changed.is_empty() {
crate::crawl::announce(config, &changed).await;
}
Ok(())
}