use std::sync::Arc;
use actix_web::{HttpResponse, get, web};
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
use tokio::sync::RwLock;
use crate::AppState;
use crate::auth::CurrentUser;
use crate::entities::prelude::*;
use crate::entities::users::Column as UserColumn;
use crate::views;
pub mod accounts;
pub mod federation;
pub mod git;
pub mod invites;
pub mod nodeinfo;
pub mod remote_forge;
pub mod repositories;
pub mod ssh_keys;
pub mod user;
// TODO: Split this? :D
pub(crate) fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(index)
.service(accounts::get_register)
.service(accounts::post_register)
.service(accounts::get_login)
.service(accounts::post_login)
.service(accounts::post_logout)
.service(federation::get_webfinger)
.service(nodeinfo::get_nodeinfo_jdd)
.service(nodeinfo::get_nodeinfo_21)
.service(
web::resource("/~{user_or_team}")
.route(
web::get()
.guard(federation::accept_ap())
.to(federation::get_actor),
)
.route(web::get().to(user::get_profile)),
)
.service(
web::resource("/~{user_or_team}/inbox")
.app_data(web::PayloadConfig::default().limit(1_048_576))
.route(web::post().to(federation::post_inbox)),
)
.service(federation::get_followers)
.service(federation::get_outbox)
.service(federation::get_note)
.service(federation::post_note)
.service(federation::post_react)
.service(federation::post_unreact)
.service(federation::post_react_remote)
.service(federation::post_unreact_remote)
.service(federation::post_announce_remote)
.service(federation::post_unannounce_remote)
.service(federation::post_follow)
.service(federation::post_unfollow)
.service(user::get_edit_profile)
.service(user::post_edit_profile)
.service(invites::get_invites)
.service(invites::post_invite)
.service(repositories::get_new_repository)
.service(repositories::post_new_repository)
.service(ssh_keys::get_ssh_keys)
.service(ssh_keys::post_ssh_key)
.service(ssh_keys::post_delete_ssh_key)
.service(remote_forge::get_remote_tickets)
.service(remote_forge::get_new_remote_ticket)
.service(remote_forge::post_remote_ticket)
// Git over HTTP, read-only. Its paths end in a literal `info/refs` or
// `git-upload-pack`, which no repository page of ours is called, so
// they cannot be reached by mistake; the `.git` in the URL is what
// `RepositoryPath::parse` requires before it names a repository at all.
.service(git::info_refs)
.service(git::upload_pack)
.service(git::receive_pack)
// The refs, tree and file browsers sit one segment deeper -
// `/{repository_slug}/refs`, `/{repository_slug}/tree`,
// `/{repository_slug}/raw` - so the repository page's own pattern
// can never reach them, and no word needs reserving as a slug:
// `/~{user}/refs` is still a repository, and so are `/~{user}/tree`
// and `/~{user}/raw`.
.service(repositories::get_refs)
.service(repositories::get_commit)
.service(repositories::get_commits)
.service(repositories::get_commits_root)
.service(repositories::get_commits_root_slash)
.service(repositories::get_compare)
.service(repositories::get_compare_root)
.service(repositories::get_compare_root_slash)
.service(repositories::get_tree)
.service(repositories::get_tree_revision)
.service(repositories::get_tree_root)
.service(repositories::get_tree_root_slash)
.service(repositories::get_raw)
.service(repositories::get_raw_revision)
// Tickets are two segments deeper than the repository page, like
// the tree browsers above, and the literal `new` address has to be
// tried before the one that takes any number as a ticket's own.
.service(repositories::tickets::get_tickets)
.service(repositories::tickets::get_new_ticket)
.service(repositories::tickets::post_ticket)
.service(
web::resource("/~{user_or_team}/{repository_slug}/tickets/{number}")
.route(
web::get()
.guard(federation::accept_ap())
.to(federation::get_ticket_ap),
)
.route(web::get().to(repositories::tickets::get_ticket))
.route(web::post().to(repositories::tickets::post_edit_ticket)),
)
.service(repositories::tickets::get_edit_ticket)
.service(repositories::tickets::post_delete_ticket)
.service(repositories::tickets::post_attachment)
.service(repositories::tickets::get_attachment)
.service(repositories::tickets::post_delete_attachment)
.service(repositories::tickets::post_comment)
.service(
web::resource("/~{user_or_team}/{repository_slug}/tickets/{number}/comments/{uuid}")
.route(
web::get()
.guard(federation::accept_ap())
.to(federation::get_ticket_comment_ap),
)
.route(web::post().to(repositories::tickets::post_edit_comment)),
)
.service(repositories::tickets::post_delete_comment)
.service(
web::resource("/~{user_or_team}/{repository_slug}/tickets/{number}/replies")
.route(web::get().to(federation::get_ticket_replies)),
)
// Patches are two segments deeper than the repository page, like
// tickets, and follow the same pattern: list, detail, edit.
.service(repositories::patches::get_patches)
.service(
web::resource("/~{user_or_team}/{repository_slug}/patches/{number}")
.route(
web::get()
.guard(federation::accept_ap())
.to(federation::get_patch_ap),
)
.route(web::get().to(repositories::patches::get_patch))
.route(web::post().to(repositories::patches::post_edit_patch)),
)
.service(repositories::patches::get_edit_patch)
.service(repositories::patches::post_merge_patch)
.service(repositories::patches::post_comment)
.service(
web::resource("/~{user_or_team}/{repository_slug}/patches/{number}/comments/{uuid}")
.route(
web::get()
.guard(federation::accept_ap())
.to(federation::get_patch_comment_ap),
)
.route(web::post().to(repositories::patches::post_edit_comment)),
)
.service(repositories::patches::post_delete_comment)
.service(
web::resource("/~{user_or_team}/{repository_slug}/patches/{number}/replies")
.route(web::get().to(federation::get_patch_replies)),
)
// Settings: visibility and label management. Owner-only, so the
// handlers refuse non-owners with 403 and anonymous visitors with 401.
.service(repositories::settings::get_settings)
.service(repositories::settings::post_settings)
.service(repositories::settings::post_label)
.service(repositories::settings::post_delete_label)
// Repository ActivityPub endpoints: inbox, followers, outbox, commits
.service(
web::resource("/~{user_or_team}/{repository_slug}/inbox")
.app_data(web::PayloadConfig::default().limit(1_048_576))
.route(web::post().to(federation::post_repo_inbox)),
)
.service(
web::resource("/~{user_or_team}/{repository_slug}/followers")
.route(web::get().to(federation::get_repo_followers)),
)
.service(
web::resource("/~{user_or_team}/{repository_slug}/outbox")
.route(web::get().to(federation::get_repo_outbox)),
)
.service(
web::resource("/~{user_or_team}/{repository_slug}/commits/{hash}")
.route(web::get().to(federation::get_repo_commit)),
)
// Registered last: `/~{user}/{repository_slug}` is the most general
// pattern under a profile, and every literal suffix above has to be
// tried first. Each literal suffix (`edit`, `ssh-keys`,
// `repositories`, ...) is a name a repository may not take; they are
// listed in `repositories::RESERVED_SLUGS`, which has to be kept in
// step with this list.
// Now with content negotiation: ActivityPub actor document for AP clients,
// HTML repository landing page for browsers.
.service(
web::resource("/~{user_or_team}/{repository_slug}")
.route(
web::get()
.guard(federation::accept_ap())
.to(federation::get_repo_actor),
)
.route(web::get().to(repositories::get_repository)),
);
}
#[derive(serde::Deserialize)]
struct IndexQuery {
page: Option<u64>,
}
#[get("/")]
#[tracing::instrument(skip_all)]
pub async fn index(
viewer: Option<CurrentUser>,
data: web::Data<Arc<RwLock<AppState>>>,
query: web::Query<IndexQuery>,
) -> HttpResponse {
let viewer_name = crate::auth::viewer_name(viewer.as_ref());
let state = data.read().await;
let db = state.db.clone();
let public_url = state.public_url().to_string();
// Parse page number, default to 0, invalid -> 0
let page = query.page.unwrap_or(0);
// Get registration mode (always available for landing page)
let registration_mode = crate::registration::registration_mode();
// Only show feed if logged in and federation is enabled
if viewer_name.is_none() || !crate::activitypub::enabled(&state) {
// For anonymous visitors, query instance stats
let (user_count, repository_count) = if viewer_name.is_none() {
let user_count = Users::find()
.count(&db)
.await
.unwrap_or(0);
let repository_count = Repositories::find()
.filter(crate::entities::repositories::Column::Visibility.eq(
crate::entities::repositories::Visibility::Public,
))
.count(&db)
.await
.unwrap_or(0);
(user_count, repository_count)
} else {
(0, 0)
};
return views::index(
viewer_name,
vec![],
&public_url,
page,
user_count,
repository_count,
registration_mode,
);
}
// Get the user ID from the viewer
let viewer_name_str = viewer_name.as_ref().unwrap();
let user = match Users::find()
.filter(UserColumn::Name.eq(*viewer_name_str))
.one(&db)
.await
{
Ok(Some(u)) => u,
_ => {
return views::index(
viewer_name,
vec![],
&public_url,
page,
0,
0,
registration_mode,
);
}
};
// Fetch the feed
let entries = match crate::activitypub::feed::get_home_feed(
&db,
user.id,
&public_url,
crate::views::index::FEED_PAGE_SIZE as u64,
page,
)
.await
{
Ok(entries) => entries,
Err(e) => {
tracing::warn!(error = %e, "failed to fetch home feed");
vec![]
}
};
views::index(
viewer_name,
entries,
&public_url,
page,
0,
0,
registration_mode,
)
}