Files
movies-diary/crates/presentation/src/routes.rs

545 lines
18 KiB
Rust

use std::num::NonZeroU32;
use axum::{Router, routing};
use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp};
use tower_http::{
cors::CorsLayer,
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
use crate::{handlers, state::AppState};
pub fn build_router(state: AppState, ap_router: Router) -> Router {
let rate_limit = state.app_ctx.config.rate_limit;
let ap_cfg = GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(per_minute(rate_limit / 2))
.finish()
.unwrap();
let ap_router = ap_router.layer(GovernorLayer::new(ap_cfg));
Router::new()
.route("/health", routing::get(health_handler))
.merge(html_routes(rate_limit))
.merge(api_routes(rate_limit))
.nest_service("/static", ServeDir::new("static"))
.nest_service(
"/app",
ServeDir::new("spa/dist").fallback(ServeFile::new("spa/dist/index.html")),
)
.layer(TraceLayer::new_for_http())
.with_state(state)
.merge(ap_router)
}
async fn health_handler() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ "status": "ok" }))
}
fn per_minute(n: u64) -> Quota {
let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap();
Quota::requests_per_minute(n)
}
fn html_routes(rate_limit: u64) -> Router<AppState> {
let auth = Router::new()
.route(
"/login",
routing::get(handlers::auth::get_login_page).post(handlers::auth::post_login),
)
.route("/logout", routing::get(handlers::auth::get_logout))
.route(
"/register",
routing::get(handlers::auth::get_register_page).post(handlers::auth::post_register),
)
.layer({
let cfg = GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(per_minute(rate_limit))
.finish()
.unwrap();
GovernorLayer::new(cfg)
});
let base = Router::new()
.route("/", routing::get(handlers::diary::get_activity_feed_html))
.route("/users", routing::get(handlers::users::get_users_list))
.route(
"/u/{username}",
routing::get(handlers::users::get_user_by_username),
)
.route(
"/users/{id}",
routing::get(handlers::users::get_user_profile_html),
)
.route(
"/movies/{movie_id}",
routing::get(handlers::movies::get_movie_detail_html),
)
.merge(auth)
.route(
"/reviews/new",
routing::get(handlers::diary::get_new_review_page),
)
.route("/reviews", routing::post(handlers::diary::post_review_html))
.route(
"/reviews/{id}/delete",
routing::post(handlers::diary::post_delete_review_html),
)
.route("/images/{*key}", routing::get(handlers::images::get_image))
.route(
"/posters/{path}",
routing::get(
|axum::extract::Path(p): axum::extract::Path<String>| async move {
axum::response::Redirect::permanent(&format!("/images/{}", p))
},
),
)
.route(
"/diary/export",
routing::get(handlers::diary::get_export_html),
)
.route("/import", routing::get(handlers::import::get_import_page))
.route(
"/import/upload",
routing::post(handlers::import::post_upload),
)
.route(
"/import/{id}/mapping",
routing::get(handlers::import::get_mapping_page).post(handlers::import::post_mapping),
)
.route(
"/import/{id}/preview",
routing::get(handlers::import::get_preview_page),
)
.route(
"/import/{id}/confirm",
routing::post(handlers::import::post_confirm),
)
.route(
"/import/done",
routing::get(handlers::import::get_import_done),
)
.route(
"/import/profiles/{profile_id}/delete",
routing::post(handlers::import::post_delete_profile),
)
.route("/feed.rss", routing::get(handlers::rss::get_feed))
.route(
"/users/{id}/feed.rss",
routing::get(handlers::rss::get_user_feed),
)
.route(
"/settings/profile",
routing::get(handlers::users::get_profile_settings)
.post(handlers::users::post_profile_settings),
)
.route("/tags/{tag}", routing::get(handlers::search::get_tag))
.route(
"/users/{id}/watchlist",
routing::get(handlers::watchlist::get_watchlist_page),
)
.route(
"/watchlist/add",
routing::post(handlers::watchlist::post_watchlist_add_html),
)
.route(
"/watchlist/{movie_id}/remove",
routing::post(handlers::watchlist::post_watchlist_remove_html),
)
.route(
"/settings/integrations",
routing::get(handlers::integrations::get_integrations_page),
)
.route(
"/settings/integrations/generate",
routing::post(handlers::integrations::post_generate_token),
)
.route(
"/settings/integrations/{id}/revoke",
routing::post(handlers::integrations::post_revoke_token),
)
.route(
"/watch-queue",
routing::get(handlers::integrations::get_watch_queue_page),
)
.route(
"/watch-queue/{id}/confirm",
routing::post(handlers::integrations::post_confirm_single),
)
.route(
"/watch-queue/{id}/dismiss",
routing::post(handlers::integrations::post_dismiss_single),
)
.route(
"/wrapups/{user_id}/{year}",
routing::get(handlers::wrapup::get_user_wrapup_html),
)
.route(
"/wrapups/global/{year}",
routing::get(handlers::wrapup::get_global_wrapup_html),
);
#[cfg(feature = "federation")]
let base = base.merge(federation_html_routes());
base.layer(axum::middleware::from_fn(crate::csrf::csrf_middleware))
}
#[cfg(feature = "federation")]
fn federation_html_routes() -> Router<AppState> {
Router::new()
.route(
"/users/{id}/follow",
routing::post(handlers::social::follow_remote_user),
)
.route(
"/users/{id}/unfollow",
routing::post(handlers::social::unfollow_remote_user),
)
.route(
"/users/{id}/followers/accept",
routing::post(handlers::social::accept_follower_html),
)
.route(
"/users/{id}/followers/reject",
routing::post(handlers::social::reject_follower_html),
)
.route(
"/users/{id}/followers",
routing::get(handlers::social::get_followers_collection),
)
.route(
"/users/{id}/following",
routing::get(handlers::social::get_following_collection),
)
.route(
"/users/{id}/following-list",
routing::get(handlers::social::get_following_page),
)
.route(
"/users/{id}/followers-list",
routing::get(handlers::social::get_followers_page),
)
.route(
"/users/{id}/followers/remove",
routing::post(handlers::social::remove_follower_html),
)
.route(
"/admin/blocked-domains",
routing::get(handlers::social::get_blocked_domains_page)
.post(handlers::social::post_blocked_domain),
)
.route(
"/admin/blocked-domains/remove",
routing::post(handlers::social::post_remove_blocked_domain),
)
.route(
"/social/blocked",
routing::get(handlers::social::get_blocked_actors_page),
)
.route(
"/social/block",
routing::post(handlers::social::post_block_actor_html),
)
.route(
"/social/unblock",
routing::post(handlers::social::post_unblock_actor),
)
}
fn cors_layer() -> CorsLayer {
use axum::http::{HeaderName, Method};
use tower_http::cors::AllowOrigin;
let origins = std::env::var("CORS_ORIGINS").unwrap_or_default();
let layer = CorsLayer::new()
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([
HeaderName::from_static("content-type"),
HeaderName::from_static("authorization"),
]);
if origins.is_empty() || origins == "*" {
layer.allow_origin(AllowOrigin::any())
} else {
let parsed: Vec<_> = origins
.split(',')
.filter_map(|s| {
let trimmed = s.trim();
match trimmed.parse() {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!("ignoring invalid CORS origin {trimmed:?}: {e}");
None
}
}
})
.collect();
layer
.allow_origin(AllowOrigin::list(parsed))
.allow_credentials(true)
}
}
fn api_routes(rate_limit: u64) -> Router<AppState> {
let cfg = GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(per_minute(rate_limit))
.finish()
.unwrap();
let base = Router::new()
.route("/diary", routing::get(handlers::diary::get_diary))
.route(
"/movies/{id}/history",
routing::get(handlers::movies::get_review_history),
)
.route("/movies", routing::get(handlers::movies::list_movies))
.route(
"/movies/{id}",
routing::get(handlers::movies::get_movie_detail),
)
.route(
"/movies/{id}/profile",
routing::get(handlers::movies::get_movie_profile),
)
.route("/reviews", routing::post(handlers::diary::post_review))
.route(
"/reviews/{id}",
routing::delete(handlers::diary::delete_review),
)
.route(
"/movies/{id}/sync-poster",
routing::post(handlers::movies::sync_poster),
)
.route("/auth/login", routing::post(handlers::auth::login))
.route("/auth/register", routing::post(handlers::auth::register))
.route("/auth/refresh", routing::post(handlers::auth::refresh))
.route("/auth/logout", routing::post(handlers::auth::api_logout))
.route("/diary/export", routing::get(handlers::diary::export_diary))
.route(
"/activity-feed",
routing::get(handlers::diary::get_activity_feed),
)
.route("/users", routing::get(handlers::users::list_users))
.route(
"/users/{id}",
routing::get(handlers::users::get_user_profile),
)
.route(
"/import/sessions",
routing::post(handlers::import::api_post_session),
)
.route(
"/import/sessions/{id}",
routing::get(handlers::import::api_get_session),
)
.route(
"/import/sessions/{id}/mapping",
routing::put(handlers::import::api_put_mapping),
)
.route(
"/import/sessions/{id}/preview",
routing::get(handlers::import::api_get_preview),
)
.route(
"/import/sessions/{id}/confirm",
routing::post(handlers::import::api_post_confirm),
)
.route(
"/import/profiles",
routing::get(handlers::import::api_get_profiles)
.post(handlers::import::api_post_profile),
)
.route(
"/import/profiles/{id}",
routing::delete(handlers::import::api_delete_profile),
)
.route(
"/import/sessions/{id}/profile/{profile_id}",
routing::put(handlers::import::api_apply_profile),
)
.route(
"/profile",
routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler),
)
.route(
"/profile/fields",
routing::put(handlers::users::update_profile_fields_handler),
)
.route("/search", routing::get(handlers::search::get_search))
.route(
"/people/{id}",
routing::get(handlers::search::get_person_handler),
)
.route(
"/people/{id}/credits",
routing::get(handlers::search::get_person_credits_handler),
)
.route(
"/watchlist",
routing::get(handlers::watchlist::get_watchlist_handler)
.post(handlers::watchlist::post_watchlist_add),
)
.route(
"/watchlist/{movie_id}",
routing::get(handlers::watchlist::get_watchlist_status)
.delete(handlers::watchlist::delete_watchlist_entry),
)
.route(
"/settings/webhook-tokens",
routing::get(handlers::webhook::get_webhook_tokens)
.post(handlers::webhook::post_generate_webhook_token),
)
.route(
"/settings/webhook-tokens/{id}",
routing::delete(handlers::webhook::delete_webhook_token),
)
.route(
"/watch-queue",
routing::get(handlers::webhook::get_watch_queue),
)
.route(
"/watch-queue/confirm",
routing::post(handlers::webhook::post_confirm_watch_events),
)
.route(
"/watch-queue/dismiss",
routing::post(handlers::webhook::post_dismiss_watch_events),
)
.route(
"/wrapups/generate",
routing::post(handlers::wrapup::post_generate),
)
.route("/wrapups", routing::get(handlers::wrapup::get_list))
.route(
"/wrapups/{id}",
routing::get(handlers::wrapup::get_status)
.delete(handlers::wrapup::delete_wrapup_handler),
)
.route(
"/wrapups/{id}/report",
routing::get(handlers::wrapup::get_report),
)
.route(
"/admin/reindex-search",
routing::post(handlers::search::post_reindex_search),
)
.route(
"/goals",
routing::get(handlers::goals::list_goals).post(handlers::goals::create_goal),
)
.route(
"/goals/{year}",
routing::put(handlers::goals::update_goal).delete(handlers::goals::delete_goal),
)
.route(
"/users/{id}/goals",
routing::get(handlers::goals::get_user_goals),
)
.route(
"/settings",
routing::get(handlers::goals::get_settings).put(handlers::goals::update_settings),
);
#[cfg(feature = "federation")]
let base = base.merge(federation_api_routes());
let webhook_cfg = GovernorConfigBuilder::default()
.with_extractor(PeerIp::default())
.expect_connect_info()
.quota_default(per_minute(rate_limit / 4))
.finish()
.unwrap();
let webhook_routes = Router::new()
.route(
"/webhooks/jellyfin",
routing::post(handlers::webhook::post_jellyfin_webhook),
)
.route(
"/webhooks/plex",
routing::post(handlers::webhook::post_plex_webhook),
)
.layer(GovernorLayer::new(webhook_cfg));
Router::new()
.nest("/api/v1", base.layer(GovernorLayer::new(cfg)))
.nest("/api/v1", webhook_routes)
.layer(cors_layer())
}
#[cfg(feature = "federation")]
fn federation_api_routes() -> Router<AppState> {
Router::new()
.route(
"/social/following",
routing::get(handlers::social::get_following),
)
.route(
"/social/followers",
routing::get(handlers::social::get_followers),
)
.route(
"/social/followers/pending",
routing::get(handlers::social::get_pending_followers),
)
.route("/social/follow", routing::post(handlers::social::follow))
.route(
"/social/unfollow",
routing::post(handlers::social::unfollow),
)
.route(
"/social/followers/accept",
routing::post(handlers::social::accept_follower),
)
.route(
"/social/followers/reject",
routing::post(handlers::social::reject_follower),
)
.route(
"/social/followers/remove",
routing::post(handlers::social::remove_follower),
)
.route(
"/admin/blocked-domains",
routing::get(handlers::social::get_blocked_domains_admin)
.post(handlers::social::add_blocked_domain_admin),
)
.route(
"/admin/blocked-domains/{domain}",
routing::delete(handlers::social::remove_blocked_domain_admin),
)
.route(
"/social/block",
routing::post(handlers::social::block_actor_api),
)
.route(
"/social/unblock",
routing::post(handlers::social::unblock_actor_api),
)
.route(
"/social/blocked",
routing::get(handlers::social::get_blocked_actors_api),
)
.route(
"/users/{id}/following",
routing::get(handlers::social::get_user_following),
)
.route(
"/users/{id}/followers",
routing::get(handlers::social::get_user_followers),
)
}