pub mod auth;
pub mod events;
pub mod fonts;
pub mod glw;
pub mod groups;
pub mod index;
pub mod invitations;
pub mod library_pages;
pub mod logos;
pub mod notecard;
pub mod notecards;
pub mod pages;
pub mod region;
pub mod render;
pub mod renders;
pub mod result;
pub mod text;
pub mod themes;
pub mod users;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::http::header;
use axum::middleware;
use axum::routing::{get, patch, post};
use tower_http::compression::CompressionLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
const REQUEST_BODY_LIMIT: usize = 1024 * 1024;
const LOGO_REQUEST_BODY_LIMIT: usize = 6 * 1024 * 1024;
const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
img-src 'self' https://secondlife-maps-cdn.akamaized.net blob:; \
script-src 'self'; \
style-src 'self' 'unsafe-inline'; \
connect-src 'self'; \
frame-ancestors 'none'; \
base-uri 'none'; \
form-action 'self'";
use crate::auth::require_session;
use crate::csrf::require_same_origin;
use crate::state::AppState;
pub fn build(state: AppState) -> Router {
let public_csrf = Router::new()
.route("/api/auth/login", post(auth::login))
.route("/api/auth/logout", post(auth::logout))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_same_origin,
));
let public_open = Router::new()
.route("/login", get(pages::login_page))
.route("/set-password", get(pages::set_password_page))
.route("/api/auth/register", post(auth::register))
.route("/api/auth/set-password", post(auth::set_password))
.route("/api/auth/me", get(auth::me))
.route("/static/app.js", get(index::app_js))
.route("/static/style.css", get(index::style_css))
.route("/static/library.js", get(library_pages::library_js))
.route("/static/groups.js", get(library_pages::groups_js))
.route("/static/invitations.js", get(library_pages::invitations_js))
.route("/static/profile.js", get(library_pages::profile_js))
.route("/static/modal.js", get(library_pages::modal_js))
.route("/static/login.js", get(pages::login_js))
.route("/static/set_password.js", get(pages::set_password_js));
let protected = Router::new()
.route("/", get(index::index))
.route("/library", get(library_pages::library))
.route("/groups", get(library_pages::groups))
.route("/groups/{id}", get(library_pages::groups))
.route("/invitations", get(library_pages::invitations))
.route(
"/api/notecard/derive-rectangle",
post(notecard::derive_rectangle),
)
.route("/api/region/lookup", post(region::lookup))
.route("/api/render/grid-rectangle", post(render::grid_rectangle))
.route("/api/render/usb-notecard", post(render::usb_notecard))
.route(
"/api/render/placement-slots/grid-rectangle",
post(render::free_placement_slots_grid_rectangle),
)
.route(
"/api/render/placement-slots/usb-notecard",
post(render::free_placement_slots_usb_notecard),
)
.route("/api/render/glw-preview", post(render::glw_preview))
.route("/api/render/route-preview", post(render::route_preview))
.route(
"/api/render/glw-legend-preview",
post(render::glw_legend_preview),
)
.route(
"/api/render/placement-preview/grid-rectangle",
post(render::placement_preview_grid_rectangle),
)
.route(
"/api/render/placement-preview/usb-notecard",
post(render::placement_preview_usb_notecard),
)
.route(
"/api/render/region-overlay-preview",
post(render::region_overlay_preview),
)
.route("/api/render/{id}/events", get(events::events))
.route("/api/render/{id}/image", get(result::image))
.route(
"/api/render/{id}/image-without-route",
get(result::image_without_route),
)
.route("/api/render/{id}/metadata", get(result::metadata))
.route("/api/fonts", get(fonts::list))
.route("/api/text/measure", post(text::measure))
.route("/api/groups", get(groups::list_mine).post(groups::create))
.route(
"/api/groups/{id}",
get(groups::get)
.patch(groups::rename)
.delete(groups::delete),
)
.route("/api/groups/{id}/members", get(groups::list_members))
.route(
"/api/groups/{id}/members/{user_id}",
patch(groups::set_member_role).delete(groups::remove_member),
)
.route("/api/groups/{id}/leave", post(groups::leave))
.route(
"/api/groups/{id}/invitations",
get(invitations::list_for_group).post(invitations::create),
)
.route("/api/invitations", get(invitations::list_mine))
.route("/api/invitations/{id}/accept", post(invitations::accept))
.route("/api/invitations/{id}/reject", post(invitations::reject))
.route(
"/api/notecards",
get(notecards::list).post(notecards::create),
)
.route(
"/api/notecards/{id}",
get(notecards::get).delete(notecards::delete),
)
.route("/api/notecards/{id}/text", get(notecards::download_text))
.route("/api/renders", get(renders::list))
.route(
"/api/renders/{id}",
get(renders::get).delete(renders::delete),
)
.route("/api/renders/{id}/image", get(renders::image))
.route(
"/api/renders/{id}/image-without-route",
get(renders::image_without_route),
)
.route("/api/renders/{id}/download", get(renders::download))
.route(
"/api/renders/{id}/download-without-route",
get(renders::download_without_route),
)
.route("/api/renders/{id}/metadata", get(renders::metadata))
.route("/api/renders/{id}/settings", get(renders::settings))
.route("/api/glw", get(glw::list))
.route("/api/glw/style-defaults", get(glw::style_defaults))
.route(
"/api/glw/{id}",
get(glw::get).patch(glw::rename).delete(glw::delete),
)
.route("/api/glw/{id}/payload", get(glw::payload))
.route("/api/themes", get(themes::list).post(themes::create))
.route(
"/api/themes/{id}",
get(themes::get)
.patch(themes::update)
.delete(themes::delete),
)
.route(
"/api/logos",
get(logos::list)
.post(logos::create)
.layer(DefaultBodyLimit::max(LOGO_REQUEST_BODY_LIMIT)),
)
.route(
"/api/logos/{id}",
get(logos::get).patch(logos::rename).delete(logos::delete),
)
.route("/api/logos/{id}/image", get(logos::image))
.route("/profile", get(library_pages::profile))
.route("/profile/{id}", get(library_pages::profile))
.route("/api/users/me", axum::routing::delete(users::delete_me))
.route(
"/api/users/me/preferences",
patch(users::update_preferences),
)
.route(
"/api/users/me/colors",
get(users::list_colors).post(users::add_color),
)
.route(
"/api/users/me/colors/{color}",
axum::routing::delete(users::delete_color),
)
.route("/api/users/{id}", get(users::get))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_session,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_same_origin,
));
public_open
.merge(public_csrf)
.merge(protected)
.layer(DefaultBodyLimit::max(REQUEST_BODY_LIMIT))
.layer(SetResponseHeaderLayer::if_not_present(
header::CONTENT_SECURITY_POLICY,
axum::http::HeaderValue::from_static(CONTENT_SECURITY_POLICY),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::REFERRER_POLICY,
axum::http::HeaderValue::from_static("strict-origin-when-cross-origin"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::X_FRAME_OPTIONS,
axum::http::HeaderValue::from_static("DENY"),
))
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::CONTENT_SECURITY_POLICY;
#[test]
fn csp_img_src_allows_blob() {
let img_src_allows_blob = CONTENT_SECURITY_POLICY
.split(';')
.map(str::trim)
.filter(|d| d.starts_with("img-src"))
.any(|d| d.split_whitespace().any(|s| s == "blob:"));
assert!(
img_src_allows_blob,
"img-src must allow blob: for the GLW preview overlay; CSP was: {CONTENT_SECURITY_POLICY}"
);
}
}