1use axum::{
75 body::Body,
76 extract::{Path, Query, State},
77 http::{header, StatusCode},
78 response::{IntoResponse, Response},
79 routing::get,
80 Json, Router,
81};
82use guts_compat::{
83 base64_encode, create_archive, is_readme_file, paginate, AddSshKeyRequest, ArchiveEntry,
84 ArchiveFormat, CompatError, CompatStore, ContentEntry, ContentType, CreateReleaseRequest,
85 CreateTokenRequest, CreateUserRequest, PaginationParams, UpdateReleaseRequest,
86 UpdateUserRequest, User,
87};
88use guts_storage::{GitObject, ObjectId, ObjectType, Reference, Repository};
89use serde::{Deserialize, Serialize};
90
91use crate::api::AppState;
92
93pub fn compat_routes() -> Router<AppState> {
95 Router::new()
96 .route("/api/users", get(list_users).post(create_user))
98 .route(
99 "/api/users/{username}",
100 get(get_user).patch(update_user_by_name),
101 )
102 .route(
103 "/api/user",
104 get(get_current_user).patch(update_current_user),
105 )
106 .route("/api/user/tokens", get(list_tokens).post(create_token))
108 .route("/api/user/tokens/{id}", get(get_token).delete(revoke_token))
109 .route("/api/user/keys", get(list_ssh_keys).post(add_ssh_key))
111 .route(
112 "/api/user/keys/{id}",
113 get(get_ssh_key).delete(remove_ssh_key),
114 )
115 .route(
117 "/api/repos/{owner}/{name}/releases",
118 get(list_releases).post(create_release),
119 )
120 .route(
121 "/api/repos/{owner}/{name}/releases/latest",
122 get(get_latest_release),
123 )
124 .route(
125 "/api/repos/{owner}/{name}/releases/tags/{tag}",
126 get(get_release_by_tag),
127 )
128 .route(
129 "/api/repos/{owner}/{name}/releases/{id}",
130 get(get_release)
131 .patch(update_release)
132 .delete(delete_release),
133 )
134 .route("/api/repos/{owner}/{name}/contents", get(get_contents_root))
136 .route(
137 "/api/repos/{owner}/{name}/contents/{*path}",
138 get(get_contents),
139 )
140 .route("/api/repos/{owner}/{name}/readme", get(get_readme))
141 .route("/api/repos/{owner}/{name}/tarball/{ref}", get(get_tarball))
143 .route("/api/repos/{owner}/{name}/zipball/{ref}", get(get_zipball))
144 .route("/api/rate_limit", get(get_rate_limit))
146}
147
148struct CompatApiError(CompatError);
152
153impl From<CompatError> for CompatApiError {
154 fn from(err: CompatError) -> Self {
155 Self(err)
156 }
157}
158
159impl IntoResponse for CompatApiError {
160 fn into_response(self) -> Response {
161 let status =
162 StatusCode::from_u16(self.0.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
163 let message = self.0.github_message();
164
165 (
166 status,
167 Json(ErrorResponse {
168 message: message.to_string(),
169 documentation_url: None,
170 }),
171 )
172 .into_response()
173 }
174}
175
176#[derive(Serialize)]
177struct ErrorResponse {
178 message: String,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 documentation_url: Option<String>,
181}
182
183fn get_identity_from_header(headers: &axum::http::HeaderMap) -> Option<String> {
187 headers
188 .get("X-Guts-Identity")
189 .and_then(|v| v.to_str().ok())
190 .map(|s| s.to_string())
191}
192
193fn get_user_from_identity(compat: &CompatStore, identity: &str) -> Option<User> {
195 compat
196 .users
197 .get_by_username(identity)
198 .or_else(|| compat.users.get_by_public_key(identity))
199}
200
201async fn list_users(
205 State(state): State<AppState>,
206 Query(params): Query<PaginationParams>,
207) -> impl IntoResponse {
208 let users = state.compat.users.list();
209 let profiles: Vec<_> = users.iter().map(|u| u.to_profile(0, 0, 0)).collect();
210 let response = paginate(&profiles, ¶ms);
211 Json(response.items)
212}
213
214async fn create_user(
216 State(state): State<AppState>,
217 Json(req): Json<CreateUserRequest>,
218) -> Result<impl IntoResponse, CompatApiError> {
219 let mut user = state.compat.users.create(req.username, req.public_key)?;
220
221 if let Some(email) = req.email {
222 user.email = Some(email);
223 }
224 if let Some(name) = req.name {
225 user.display_name = Some(name);
226 }
227 if user.email.is_some() || user.display_name.is_some() {
228 user = state.compat.users.update(user)?;
229 }
230
231 Ok((StatusCode::CREATED, Json(user.to_profile(0, 0, 0))))
232}
233
234async fn get_user(
236 State(state): State<AppState>,
237 Path(username): Path<String>,
238) -> Result<impl IntoResponse, CompatApiError> {
239 let user = state
240 .compat
241 .users
242 .get_by_username(&username)
243 .ok_or(CompatError::UserNotFound(username))?;
244
245 let repos = state.repos.list();
247 let repo_count = repos.iter().filter(|r| r.owner == user.username).count() as u64;
248
249 Ok(Json(user.to_profile(repo_count, 0, 0)))
250}
251
252async fn update_user_by_name(
254 State(state): State<AppState>,
255 Path(username): Path<String>,
256 Json(req): Json<UpdateUserRequest>,
257) -> Result<impl IntoResponse, CompatApiError> {
258 let mut user = state
259 .compat
260 .users
261 .get_by_username(&username)
262 .ok_or(CompatError::UserNotFound(username))?;
263
264 if let Some(name) = req.name {
265 user.display_name = Some(name);
266 }
267 if let Some(email) = req.email {
268 user.email = Some(email);
269 }
270 if let Some(bio) = req.bio {
271 user.bio = Some(bio);
272 }
273 if let Some(location) = req.location {
274 user.location = Some(location);
275 }
276 if let Some(blog) = req.blog {
277 user.website = Some(blog);
278 }
279 if let Some(email_public) = req.email_public {
280 user.email_public = email_public;
281 }
282
283 user.touch();
284 let updated = state.compat.users.update(user)?;
285
286 Ok(Json(updated.to_profile(0, 0, 0)))
287}
288
289async fn get_current_user(
291 State(state): State<AppState>,
292 headers: axum::http::HeaderMap,
293) -> Result<impl IntoResponse, CompatApiError> {
294 let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
295
296 let user = get_user_from_identity(&state.compat, &identity)
297 .ok_or(CompatError::UserNotFound(identity))?;
298
299 let repos = state.repos.list();
300 let repo_count = repos.iter().filter(|r| r.owner == user.username).count() as u64;
301
302 Ok(Json(user.to_profile(repo_count, 0, 0)))
303}
304
305async fn update_current_user(
307 State(state): State<AppState>,
308 headers: axum::http::HeaderMap,
309 Json(req): Json<UpdateUserRequest>,
310) -> Result<impl IntoResponse, CompatApiError> {
311 let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
312
313 let mut user = get_user_from_identity(&state.compat, &identity)
314 .ok_or(CompatError::UserNotFound(identity))?;
315
316 if let Some(name) = req.name {
317 user.display_name = Some(name);
318 }
319 if let Some(email) = req.email {
320 user.email = Some(email);
321 }
322 if let Some(bio) = req.bio {
323 user.bio = Some(bio);
324 }
325 if let Some(location) = req.location {
326 user.location = Some(location);
327 }
328 if let Some(blog) = req.blog {
329 user.website = Some(blog);
330 }
331 if let Some(email_public) = req.email_public {
332 user.email_public = email_public;
333 }
334
335 user.touch();
336 let updated = state.compat.users.update(user)?;
337
338 Ok(Json(updated.to_profile(0, 0, 0)))
339}
340
341async fn list_tokens(
345 State(state): State<AppState>,
346 headers: axum::http::HeaderMap,
347) -> Result<impl IntoResponse, CompatApiError> {
348 let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
349
350 let user = get_user_from_identity(&state.compat, &identity)
351 .ok_or(CompatError::UserNotFound(identity))?;
352
353 let tokens = state.compat.tokens.list_for_user(user.id);
354 let responses: Vec<_> = tokens.iter().map(|t| t.to_response(None)).collect();
355
356 Ok(Json(responses))
357}
358
359async fn create_token(
361 State(state): State<AppState>,
362 headers: axum::http::HeaderMap,
363 Json(req): Json<CreateTokenRequest>,
364) -> Result<impl IntoResponse, CompatApiError> {
365 let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
366
367 let user = get_user_from_identity(&state.compat, &identity)
368 .ok_or(CompatError::UserNotFound(identity))?;
369
370 let expires_at = req.expires_in_days.map(|days| {
371 std::time::SystemTime::now()
372 .duration_since(std::time::UNIX_EPOCH)
373 .unwrap()
374 .as_secs()
375 + (days as u64 * 86400)
376 });
377
378 let (token, plaintext) = state
379 .compat
380 .tokens
381 .create(user.id, req.name, req.scopes, expires_at)?;
382
383 Ok((
384 StatusCode::CREATED,
385 Json(token.to_response(Some(&plaintext))),
386 ))
387}
388
389async fn get_token(
391 State(state): State<AppState>,
392 headers: axum::http::HeaderMap,
393 Path(id): Path<u64>,
394) -> Result<impl IntoResponse, CompatApiError> {
395 let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
396
397 let user = get_user_from_identity(&state.compat, &identity)
398 .ok_or(CompatError::UserNotFound(identity))?;
399
400 let token = state
401 .compat
402 .tokens
403 .get(id)
404 .filter(|t| t.user_id == user.id)
405 .ok_or(CompatError::TokenNotFound)?;
406
407 Ok(Json(token.to_response(None)))
408}
409
410async fn revoke_token(
412 State(state): State<AppState>,
413 headers: axum::http::HeaderMap,
414 Path(id): Path<u64>,
415) -> Result<impl IntoResponse, CompatApiError> {
416 let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
417
418 let user = get_user_from_identity(&state.compat, &identity)
419 .ok_or(CompatError::UserNotFound(identity))?;
420
421 let token = state
423 .compat
424 .tokens
425 .get(id)
426 .filter(|t| t.user_id == user.id)
427 .ok_or(CompatError::TokenNotFound)?;
428
429 state.compat.tokens.revoke(token.id)?;
430
431 Ok(StatusCode::NO_CONTENT)
432}
433
434async fn list_ssh_keys(
438 State(state): State<AppState>,
439 headers: axum::http::HeaderMap,
440) -> Result<impl IntoResponse, CompatApiError> {
441 let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
442
443 let user = get_user_from_identity(&state.compat, &identity)
444 .ok_or(CompatError::UserNotFound(identity))?;
445
446 let keys = state.compat.ssh_keys.list_for_user(user.id);
447 let responses: Vec<_> = keys.iter().map(|k| k.to_response()).collect();
448
449 Ok(Json(responses))
450}
451
452async fn add_ssh_key(
454 State(state): State<AppState>,
455 headers: axum::http::HeaderMap,
456 Json(req): Json<AddSshKeyRequest>,
457) -> Result<impl IntoResponse, CompatApiError> {
458 let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
459
460 let user = get_user_from_identity(&state.compat, &identity)
461 .ok_or(CompatError::UserNotFound(identity))?;
462
463 let key = state.compat.ssh_keys.add(user.id, req.title, req.key)?;
464
465 Ok((StatusCode::CREATED, Json(key.to_response())))
466}
467
468async fn get_ssh_key(
470 State(state): State<AppState>,
471 headers: axum::http::HeaderMap,
472 Path(id): Path<u64>,
473) -> Result<impl IntoResponse, CompatApiError> {
474 let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
475
476 let user = get_user_from_identity(&state.compat, &identity)
477 .ok_or(CompatError::UserNotFound(identity))?;
478
479 let key = state
480 .compat
481 .ssh_keys
482 .get(id)
483 .filter(|k| k.user_id == user.id)
484 .ok_or(CompatError::SshKeyNotFound)?;
485
486 Ok(Json(key.to_response()))
487}
488
489async fn remove_ssh_key(
491 State(state): State<AppState>,
492 headers: axum::http::HeaderMap,
493 Path(id): Path<u64>,
494) -> Result<impl IntoResponse, CompatApiError> {
495 let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
496
497 let user = get_user_from_identity(&state.compat, &identity)
498 .ok_or(CompatError::UserNotFound(identity))?;
499
500 let key = state
502 .compat
503 .ssh_keys
504 .get(id)
505 .filter(|k| k.user_id == user.id)
506 .ok_or(CompatError::SshKeyNotFound)?;
507
508 state.compat.ssh_keys.remove(key.id)?;
509
510 Ok(StatusCode::NO_CONTENT)
511}
512
513async fn list_releases(
517 State(state): State<AppState>,
518 Path((owner, name)): Path<(String, String)>,
519 Query(params): Query<PaginationParams>,
520) -> impl IntoResponse {
521 let repo_key = format!("{}/{}", owner, name);
522 let releases = state.compat.releases.list(&repo_key);
523 let responses: Vec<_> = releases.iter().map(|r| r.to_response()).collect();
524 let paginated = paginate(&responses, ¶ms);
525 Json(paginated.items)
526}
527
528async fn create_release(
530 State(state): State<AppState>,
531 Path((owner, name)): Path<(String, String)>,
532 headers: axum::http::HeaderMap,
533 Json(req): Json<CreateReleaseRequest>,
534) -> Result<impl IntoResponse, CompatApiError> {
535 let identity = get_identity_from_header(&headers).unwrap_or_else(|| "anonymous".to_string());
536
537 let repo_key = format!("{}/{}", owner, name);
538 let target = req.target_commitish.unwrap_or_else(|| "main".to_string());
539
540 let mut release = state
541 .compat
542 .releases
543 .create(repo_key, req.tag_name, target, identity)?;
544
545 release.name = req.name;
546 release.body = req.body;
547 release.draft = req.draft;
548 release.prerelease = req.prerelease;
549
550 if release.draft {
551 release.published_at = None;
552 }
553
554 let updated = state.compat.releases.update(release)?;
555
556 Ok((StatusCode::CREATED, Json(updated.to_response())))
557}
558
559async fn get_latest_release(
561 State(state): State<AppState>,
562 Path((owner, name)): Path<(String, String)>,
563) -> Result<impl IntoResponse, CompatApiError> {
564 let repo_key = format!("{}/{}", owner, name);
565 let release = state
566 .compat
567 .releases
568 .get_latest(&repo_key)
569 .ok_or_else(|| CompatError::ReleaseNotFound("latest".to_string()))?;
570
571 Ok(Json(release.to_response()))
572}
573
574async fn get_release_by_tag(
576 State(state): State<AppState>,
577 Path((owner, name, tag)): Path<(String, String, String)>,
578) -> Result<impl IntoResponse, CompatApiError> {
579 let repo_key = format!("{}/{}", owner, name);
580 let release = state
581 .compat
582 .releases
583 .get_by_tag(&repo_key, &tag)
584 .ok_or(CompatError::ReleaseNotFound(tag))?;
585
586 Ok(Json(release.to_response()))
587}
588
589async fn get_release(
591 State(state): State<AppState>,
592 Path((owner, name, id)): Path<(String, String, u64)>,
593) -> Result<impl IntoResponse, CompatApiError> {
594 let repo_key = format!("{}/{}", owner, name);
595 let release = state
596 .compat
597 .releases
598 .get(id)
599 .filter(|r| r.repo_key == repo_key)
600 .ok_or_else(|| CompatError::ReleaseNotFound(id.to_string()))?;
601
602 Ok(Json(release.to_response()))
603}
604
605async fn update_release(
607 State(state): State<AppState>,
608 Path((owner, name, id)): Path<(String, String, u64)>,
609 Json(req): Json<UpdateReleaseRequest>,
610) -> Result<impl IntoResponse, CompatApiError> {
611 let repo_key = format!("{}/{}", owner, name);
612 let mut release = state
613 .compat
614 .releases
615 .get(id)
616 .filter(|r| r.repo_key == repo_key)
617 .ok_or_else(|| CompatError::ReleaseNotFound(id.to_string()))?;
618
619 if let Some(tag) = req.tag_name {
620 release.tag_name = tag;
621 }
622 if let Some(target) = req.target_commitish {
623 release.target_commitish = target;
624 }
625 if let Some(name) = req.name {
626 release.name = Some(name);
627 }
628 if let Some(body) = req.body {
629 release.body = Some(body);
630 }
631 if let Some(draft) = req.draft {
632 release.draft = draft;
633 if !draft && release.published_at.is_none() {
634 release.publish();
635 }
636 }
637 if let Some(prerelease) = req.prerelease {
638 release.prerelease = prerelease;
639 }
640
641 let updated = state.compat.releases.update(release)?;
642
643 Ok(Json(updated.to_response()))
644}
645
646async fn delete_release(
648 State(state): State<AppState>,
649 Path((owner, name, id)): Path<(String, String, u64)>,
650) -> Result<impl IntoResponse, CompatApiError> {
651 let repo_key = format!("{}/{}", owner, name);
652
653 let _ = state
655 .compat
656 .releases
657 .get(id)
658 .filter(|r| r.repo_key == repo_key)
659 .ok_or_else(|| CompatError::ReleaseNotFound(id.to_string()))?;
660
661 state.compat.releases.delete(id)?;
662
663 Ok(StatusCode::NO_CONTENT)
664}
665
666#[derive(Deserialize)]
669struct ContentsQuery {
670 #[serde(rename = "ref")]
671 git_ref: Option<String>,
672}
673
674async fn get_contents_root(
676 State(state): State<AppState>,
677 Path((owner, name)): Path<(String, String)>,
678 Query(query): Query<ContentsQuery>,
679) -> Result<impl IntoResponse, CompatApiError> {
680 get_contents_internal(&state, &owner, &name, "", query.git_ref.as_deref()).await
681}
682
683async fn get_contents(
685 State(state): State<AppState>,
686 Path((owner, name, path)): Path<(String, String, String)>,
687 Query(query): Query<ContentsQuery>,
688) -> Result<impl IntoResponse, CompatApiError> {
689 get_contents_internal(&state, &owner, &name, &path, query.git_ref.as_deref()).await
690}
691
692async fn get_contents_internal(
694 state: &AppState,
695 owner: &str,
696 name: &str,
697 path: &str,
698 git_ref: Option<&str>,
699) -> Result<impl IntoResponse, CompatApiError> {
700 let repo = state
701 .repos
702 .get(owner, name)
703 .map_err(|_| CompatError::PathNotFound(format!("{}/{}", owner, name)))?;
704
705 let ref_name = git_ref.unwrap_or("HEAD");
707 let commit_sha = resolve_ref(&repo, ref_name)
708 .ok_or_else(|| CompatError::InvalidRef(ref_name.to_string()))?;
709
710 let commit = repo
712 .objects
713 .get(&commit_sha)
714 .map_err(|_| CompatError::InvalidRef(ref_name.to_string()))?;
715
716 let tree_sha =
717 parse_commit_tree(&commit).ok_or_else(|| CompatError::InvalidRef(ref_name.to_string()))?;
718
719 let entries = if path.is_empty() {
721 let tree = get_tree(&repo, &tree_sha)
723 .ok_or_else(|| CompatError::PathNotFound("root".to_string()))?;
724
725 tree.iter()
726 .map(|e| {
727 let content_type = match e.mode {
728 0o040000 => ContentType::Dir,
729 0o120000 => ContentType::Symlink,
730 0o160000 => ContentType::Submodule,
731 _ => ContentType::File,
732 };
733
734 let mut entry = match content_type {
735 ContentType::Dir => {
736 ContentEntry::dir(e.name.clone(), e.name.clone(), e.oid.to_hex())
737 }
738 ContentType::File => {
739 let size = get_blob(&repo, &e.oid).map(|b| b.len()).unwrap_or(0) as u64;
740 ContentEntry::file(e.name.clone(), e.name.clone(), e.oid.to_hex(), size)
741 }
742 _ => ContentEntry::file(e.name.clone(), e.name.clone(), e.oid.to_hex(), 0),
743 };
744 entry.content_type = content_type;
745 entry
746 })
747 .collect()
748 } else {
749 let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
751 let mut current_tree_sha = tree_sha;
752
753 for (i, part) in parts.iter().enumerate() {
754 let tree = get_tree(&repo, ¤t_tree_sha)
755 .ok_or_else(|| CompatError::PathNotFound(path.to_string()))?;
756
757 let entry = tree
758 .iter()
759 .find(|e| e.name == *part)
760 .ok_or_else(|| CompatError::PathNotFound(path.to_string()))?;
761
762 if i == parts.len() - 1 {
763 if entry.mode == 0o040000 {
765 let tree = get_tree(&repo, &entry.oid)
767 .ok_or_else(|| CompatError::PathNotFound(path.to_string()))?;
768
769 let entries: Vec<ContentEntry> = tree
770 .iter()
771 .map(|e| {
772 let full_path = format!("{}/{}", path, e.name);
773 let content_type = match e.mode {
774 0o040000 => ContentType::Dir,
775 0o120000 => ContentType::Symlink,
776 0o160000 => ContentType::Submodule,
777 _ => ContentType::File,
778 };
779
780 let mut entry = match content_type {
781 ContentType::Dir => {
782 ContentEntry::dir(e.name.clone(), full_path, e.oid.to_hex())
783 }
784 ContentType::File => {
785 let size = get_blob(&repo, &e.oid).map(|b| b.len()).unwrap_or(0)
786 as u64;
787 ContentEntry::file(
788 e.name.clone(),
789 full_path,
790 e.oid.to_hex(),
791 size,
792 )
793 }
794 _ => {
795 ContentEntry::file(e.name.clone(), full_path, e.oid.to_hex(), 0)
796 }
797 };
798 entry.content_type = content_type;
799 entry
800 })
801 .collect();
802
803 return Ok(Json(serde_json::to_value(entries).unwrap()));
804 } else {
805 let blob = get_blob(&repo, &entry.oid)
807 .ok_or_else(|| CompatError::PathNotFound(path.to_string()))?;
808
809 let content = base64_encode(&blob);
810 let file_entry = ContentEntry::file(
811 entry.name.clone(),
812 path.to_string(),
813 entry.oid.to_hex(),
814 blob.len() as u64,
815 )
816 .with_content(content);
817
818 return Ok(Json(serde_json::to_value(file_entry).unwrap()));
819 }
820 } else {
821 if entry.mode != 0o040000 {
823 return Err(CompatError::PathNotFound(path.to_string()).into());
824 }
825 current_tree_sha = entry.oid;
826 }
827 }
828
829 Vec::new()
830 };
831
832 Ok(Json(serde_json::to_value(entries).unwrap()))
833}
834
835async fn get_readme(
837 State(state): State<AppState>,
838 Path((owner, name)): Path<(String, String)>,
839 Query(query): Query<ContentsQuery>,
840) -> Result<impl IntoResponse, CompatApiError> {
841 let repo = state
842 .repos
843 .get(&owner, &name)
844 .map_err(|_| CompatError::PathNotFound(format!("{}/{}", owner, name)))?;
845
846 let ref_name = query.git_ref.as_deref().unwrap_or("HEAD");
848 let commit_sha = resolve_ref(&repo, ref_name)
849 .ok_or_else(|| CompatError::InvalidRef(ref_name.to_string()))?;
850
851 let commit = repo
852 .objects
853 .get(&commit_sha)
854 .map_err(|_| CompatError::InvalidRef(ref_name.to_string()))?;
855
856 let tree_sha =
857 parse_commit_tree(&commit).ok_or_else(|| CompatError::InvalidRef(ref_name.to_string()))?;
858
859 let tree =
860 get_tree(&repo, &tree_sha).ok_or_else(|| CompatError::PathNotFound("root".to_string()))?;
861
862 let readme_entry = tree
864 .iter()
865 .find(|e| is_readme_file(&e.name))
866 .ok_or_else(|| CompatError::PathNotFound("README".to_string()))?;
867
868 let blob = get_blob(&repo, &readme_entry.oid)
869 .ok_or_else(|| CompatError::PathNotFound("README".to_string()))?;
870
871 let content = base64_encode(&blob);
872 let entry = ContentEntry::file(
873 readme_entry.name.clone(),
874 readme_entry.name.clone(),
875 readme_entry.oid.to_hex(),
876 blob.len() as u64,
877 )
878 .with_content(content);
879
880 Ok(Json(entry))
881}
882
883async fn get_tarball(
887 State(state): State<AppState>,
888 Path((owner, name, git_ref)): Path<(String, String, String)>,
889) -> Result<Response, CompatApiError> {
890 get_archive(&state, &owner, &name, &git_ref, ArchiveFormat::TarGz).await
891}
892
893async fn get_zipball(
895 State(state): State<AppState>,
896 Path((owner, name, git_ref)): Path<(String, String, String)>,
897) -> Result<Response, CompatApiError> {
898 get_archive(&state, &owner, &name, &git_ref, ArchiveFormat::Zip).await
899}
900
901async fn get_archive(
903 state: &AppState,
904 owner: &str,
905 name: &str,
906 git_ref: &str,
907 format: ArchiveFormat,
908) -> Result<Response, CompatApiError> {
909 let repo = state
910 .repos
911 .get(owner, name)
912 .map_err(|_| CompatError::PathNotFound(format!("{}/{}", owner, name)))?;
913
914 let commit_sha =
916 resolve_ref(&repo, git_ref).ok_or_else(|| CompatError::InvalidRef(git_ref.to_string()))?;
917
918 let commit = repo
919 .objects
920 .get(&commit_sha)
921 .map_err(|_| CompatError::InvalidRef(git_ref.to_string()))?;
922
923 let tree_sha =
924 parse_commit_tree(&commit).ok_or_else(|| CompatError::InvalidRef(git_ref.to_string()))?;
925
926 let mut entries = Vec::new();
928 collect_tree_entries(&repo, tree_sha, "", &mut entries);
929
930 let prefix = format!("{}-{}", name, git_ref.replace('/', "-"));
932 let archive = create_archive(format, prefix.clone(), entries)
933 .map_err(|e| CompatError::ArchiveFailed(e.to_string()))?;
934
935 let filename = format.filename(name, git_ref);
936 let content_type = format.content_type();
937
938 Ok(Response::builder()
939 .status(StatusCode::OK)
940 .header(header::CONTENT_TYPE, content_type)
941 .header(
942 header::CONTENT_DISPOSITION,
943 format!("attachment; filename=\"{}\"", filename),
944 )
945 .body(Body::from(archive))
946 .unwrap())
947}
948
949fn collect_tree_entries(
951 repo: &Repository,
952 tree_sha: ObjectId,
953 prefix: &str,
954 entries: &mut Vec<ArchiveEntry>,
955) {
956 if let Some(tree) = get_tree(repo, &tree_sha) {
957 for entry in &tree {
958 let path = if prefix.is_empty() {
959 entry.name.clone()
960 } else {
961 format!("{}/{}", prefix, entry.name)
962 };
963
964 if entry.mode == 0o040000 {
965 collect_tree_entries(repo, entry.oid, &path, entries);
967 } else if let Some(blob) = get_blob(repo, &entry.oid) {
968 let archive_entry = if entry.mode == 0o100755 {
970 ArchiveEntry::executable(path, blob)
971 } else {
972 ArchiveEntry::file(path, blob)
973 };
974 entries.push(archive_entry);
975 }
976 }
977 }
978}
979
980async fn get_rate_limit(
984 State(state): State<AppState>,
985 headers: axum::http::HeaderMap,
986) -> impl IntoResponse {
987 let identity = get_identity_from_header(&headers).unwrap_or_else(|| "anonymous".to_string());
988 let authenticated = state.compat.users.get_by_username(&identity).is_some();
989
990 let response = state
991 .compat
992 .rate_limiter
993 .get_response(&identity, authenticated);
994
995 Json(response)
996}
997
998struct TreeEntry {
1002 name: String,
1003 mode: u32,
1004 oid: ObjectId,
1005}
1006
1007fn parse_commit_tree(commit: &GitObject) -> Option<ObjectId> {
1009 if commit.object_type != ObjectType::Commit {
1010 return None;
1011 }
1012
1013 let content = String::from_utf8_lossy(&commit.data);
1014 for line in content.lines() {
1015 if let Some(tree_hex) = line.strip_prefix("tree ") {
1016 return ObjectId::from_hex(tree_hex.trim()).ok();
1017 }
1018 }
1019
1020 None
1021}
1022
1023fn parse_tree_entries(data: &[u8]) -> Vec<TreeEntry> {
1025 let mut entries = Vec::new();
1026 let mut i = 0;
1027
1028 while i < data.len() {
1029 let space_pos = match data[i..].iter().position(|&b| b == b' ') {
1031 Some(pos) => pos,
1032 None => break,
1033 };
1034
1035 let mode_str = String::from_utf8_lossy(&data[i..i + space_pos]);
1036 let mode = u32::from_str_radix(&mode_str, 8).unwrap_or(0);
1037 i += space_pos + 1;
1038
1039 let null_pos = match data[i..].iter().position(|&b| b == 0) {
1041 Some(pos) => pos,
1042 None => break,
1043 };
1044
1045 let name = String::from_utf8_lossy(&data[i..i + null_pos]).to_string();
1046 i += null_pos + 1;
1047
1048 if i + 20 > data.len() {
1050 break;
1051 }
1052 let mut sha_bytes = [0u8; 20];
1053 sha_bytes.copy_from_slice(&data[i..i + 20]);
1054 let oid = ObjectId::from_bytes(sha_bytes);
1055 i += 20;
1056
1057 entries.push(TreeEntry { name, mode, oid });
1058 }
1059
1060 entries
1061}
1062
1063fn get_tree(repo: &Repository, id: &ObjectId) -> Option<Vec<TreeEntry>> {
1065 let obj = repo.objects.get(id).ok()?;
1066 if obj.object_type != ObjectType::Tree {
1067 return None;
1068 }
1069 Some(parse_tree_entries(&obj.data))
1070}
1071
1072fn get_blob(repo: &Repository, id: &ObjectId) -> Option<Vec<u8>> {
1074 let obj = repo.objects.get(id).ok()?;
1075 if obj.object_type != ObjectType::Blob {
1076 return None;
1077 }
1078 Some(obj.data.to_vec())
1079}
1080
1081fn resolve_ref(repo: &Repository, ref_name: &str) -> Option<ObjectId> {
1085 if let Ok(reference) = repo.refs.get(ref_name) {
1087 return Some(resolve_reference(repo, reference));
1088 }
1089
1090 let branch_ref = format!("refs/heads/{}", ref_name);
1092 if let Ok(reference) = repo.refs.get(&branch_ref) {
1093 return Some(resolve_reference(repo, reference));
1094 }
1095
1096 let tag_ref = format!("refs/tags/{}", ref_name);
1098 if let Ok(reference) = repo.refs.get(&tag_ref) {
1099 return Some(resolve_reference(repo, reference));
1100 }
1101
1102 if ref_name.len() >= 7 {
1104 for sha in repo.objects.list_objects() {
1106 let sha_str = sha.to_hex();
1107 if sha_str.starts_with(ref_name) {
1108 return Some(sha);
1109 }
1110 }
1111 }
1112
1113 None
1114}
1115
1116fn resolve_reference(repo: &Repository, reference: Reference) -> ObjectId {
1118 match reference {
1119 Reference::Direct(oid) => oid,
1120 Reference::Symbolic(name) => {
1121 if let Ok(r) = repo.refs.get(&name) {
1122 resolve_reference(repo, r)
1123 } else {
1124 ObjectId::from_bytes([0u8; 20])
1126 }
1127 }
1128 }
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133 use super::*;
1134
1135 #[test]
1136 fn test_error_response() {
1137 let err = CompatApiError(CompatError::UserNotFound("test".into()));
1138 let response = err.into_response();
1139 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1140 }
1141}