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