anvilsign in

collin/anvil

1//! Repository records: creation (DB row + on-disk bare repo) and lookup.
2
3use std::path::Path;
4
5use crate::{
6 error::{
7 Error,
8 Result,
9 },
10 models::{
11 Repository,
12 User,
13 },
14 storage,
15};
16
17/// A repository joined with its owner's username, for listing pages.
18pub struct RepoWithOwner {
19 pub owner_id: i64,
20 pub owner: String,
21 pub name: String,
22 pub description: String,
23 pub is_private: bool,
24 pub default_branch: String,
25}
26
27/// Create a repository owned by `owner`: insert the database row and initialize
28/// the bare repository on disk. If the on-disk init fails, the row is rolled
29/// back (deleted) so no orphan remains.
30pub async fn create(
31 db: &toasty::Db,
32 repositories_dir: &Path,
33 owner: &User,
34 name: &str,
35 description: &str,
36 is_private: bool,
37) -> Result<Repository> {
38 validate_name(name)?;
39 if find(db, owner.id, name).await?.is_some() {
40 return Err(Error::AlreadyExists(format!(
41 "repository {}/{name}",
42 owner.username
43 )));
44 }
45
46 let mut conn = db.clone();
47 let repo = toasty::create!(Repository {
48 owner_id: owner.id,
49 name: name,
50 description: description,
51 is_private: is_private,
52 default_branch: "main",
53 created_at: crate::now(),
54 mirror_url: "",
55 })
56 .exec(&mut conn)
57 .await?;
58
59 // Create the bare repo on disk; on failure, remove the row we just wrote.
60 if let Err(e) = storage::create_bare(
61 repositories_dir,
62 &owner.username,
63 name,
64 &repo.default_branch,
65 ) {
66 let _ = repo.delete().exec(&mut conn).await;
67 return Err(e);
68 }
69 Ok(repo)
70}
71
72/// Push-to-create: create `owner_username/name` on first push, when the
73/// authenticated pusher is allowed to — the pusher *is* the owner, or is an
74/// admin. Created repositories are private (flip in settings afterwards).
75/// `Ok(None)` means the policy said no; callers fall back to not-found so
76/// nothing about the namespace leaks.
77pub async fn create_on_push(
78 db: &toasty::Db,
79 repositories_dir: &Path,
80 owner_username: &str,
81 name: &str,
82 pusher: &User,
83) -> Result<Option<Repository>> {
84 let Some(owner) = crate::users::find_by_username(db, owner_username).await? else {
85 return Ok(None);
86 };
87 if pusher.id != owner.id && !pusher.is_admin {
88 return Ok(None);
89 }
90 let repo = create(db, repositories_dir, &owner, name, "", true).await?;
91 tracing::info!(
92 "push-to-create: {}/{name} (by {})",
93 owner.username,
94 pusher.username
95 );
96 Ok(Some(repo))
97}
98
99/// Find a repository by its id.
100pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<Repository>> {
101 let mut db = db.clone();
102 let repo = Repository::filter(Repository::fields().id().eq(id))
103 .first()
104 .exec(&mut db)
105 .await?;
106 Ok(repo)
107}
108
109/// Update a repository's description, visibility, and push-mirror URL.
110pub async fn update_settings(
111 db: &toasty::Db,
112 repo_id: i64,
113 description: &str,
114 is_private: bool,
115 mirror_url: &str,
116) -> Result<()> {
117 let mut conn = db.clone();
118 let Some(mut repo) = Repository::filter(Repository::fields().id().eq(repo_id))
119 .first()
120 .exec(&mut conn)
121 .await?
122 else {
123 return Err(Error::NotFound(format!("repository id {repo_id}")));
124 };
125 let mut conn = db.clone();
126 repo.update()
127 .description(description)
128 .is_private(is_private)
129 .mirror_url(mirror_url.trim())
130 .exec(&mut conn)
131 .await?;
132 Ok(())
133}
134
135/// Find a repository by owner id and name.
136pub async fn find(db: &toasty::Db, owner_id: i64, name: &str) -> Result<Option<Repository>> {
137 let mut db = db.clone();
138 let repo = Repository::filter(Repository::fields().owner_id().eq(owner_id))
139 .filter(Repository::fields().name().eq(name))
140 .first()
141 .exec(&mut db)
142 .await?;
143 Ok(repo)
144}
145
146/// List all repositories owned by `owner_id`.
147pub async fn list_by_owner(db: &toasty::Db, owner_id: i64) -> Result<Vec<Repository>> {
148 let mut db = db.clone();
149 let repos = Repository::filter(Repository::fields().owner_id().eq(owner_id))
150 .exec(&mut db)
151 .await?;
152 Ok(repos)
153}
154
155/// List all repositories with their owner usernames, ordered by owner then name.
156pub async fn list_all_with_owner(db: &toasty::Db) -> Result<Vec<RepoWithOwner>> {
157 let mut conn = db.clone();
158 let users = User::all().exec(&mut conn).await?;
159 let owner_name: std::collections::HashMap<i64, String> =
160 users.into_iter().map(|u| (u.id, u.username)).collect();
161
162 let mut conn = db.clone();
163 let repos = Repository::all().exec(&mut conn).await?;
164 let mut out: Vec<RepoWithOwner> = repos
165 .into_iter()
166 .map(|r| RepoWithOwner {
167 owner: owner_name.get(&r.owner_id).cloned().unwrap_or_default(),
168 owner_id: r.owner_id,
169 name: r.name,
170 description: r.description,
171 is_private: r.is_private,
172 default_branch: r.default_branch,
173 })
174 .collect();
175 out.sort_by(|a, b| (&a.owner, &a.name).cmp(&(&b.owner, &b.name)));
176 Ok(out)
177}
178
179/// Reject names that are empty or contain path separators / traversal.
180fn validate_name(name: &str) -> Result<()> {
181 if name.is_empty() {
182 return Err(Error::Invalid("repository name must not be empty".into()));
183 }
184 if name.contains('/') || name.contains('\\') || name.contains("..") {
185 return Err(Error::Invalid(format!("invalid repository name: {name:?}")));
186 }
187 Ok(())
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 /// Push-to-create only fires for the namespace owner or an admin, and the
195 /// repos it creates are private.
196 #[tokio::test]
197 async fn create_on_push_policy() {
198 let dir = tempfile::tempdir().unwrap();
199 let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
200 let repos_dir = dir.path().join("repos");
201
202 let alice = crate::users::create(&db, "alice", "", "pw-alice-1", false)
203 .await
204 .unwrap();
205 let bob = crate::users::create(&db, "bob", "", "pw-bob-1", false)
206 .await
207 .unwrap();
208 let root = crate::users::create(&db, "root", "", "pw-root-1", true)
209 .await
210 .unwrap();
211
212 // A stranger can't create into someone else's namespace…
213 let denied = create_on_push(&db, &repos_dir, "alice", "proj", &bob)
214 .await
215 .unwrap();
216 assert!(denied.is_none());
217 // …nor into a namespace with no such user.
218 let nobody = create_on_push(&db, &repos_dir, "ghost", "proj", &bob)
219 .await
220 .unwrap();
221 assert!(nobody.is_none());
222
223 // The owner can, and gets a private repo on disk.
224 let repo = create_on_push(&db, &repos_dir, "alice", "proj", &alice)
225 .await
226 .unwrap()
227 .expect("owner may push-create");
228 assert!(repo.is_private);
229 assert!(storage::repo_path(&repos_dir, "alice", "proj").exists());
230
231 // An admin can create into any namespace.
232 let by_admin = create_on_push(&db, &repos_dir, "bob", "tool", &root)
233 .await
234 .unwrap();
235 assert!(by_admin.is_some());
236
237 // Re-creating an existing repo is an error (callers never reach this:
238 // they only call after a failed lookup).
239 assert!(
240 create_on_push(&db, &repos_dir, "alice", "proj", &alice)
241 .await
242 .is_err()
243 );
244 }
245}