diff options
Diffstat (limited to 'kittybox-rs/src')
-rw-r--r-- | kittybox-rs/src/frontend/onboarding.rs | 7 | ||||
-rw-r--r-- | kittybox-rs/src/index.html | 182 | ||||
-rw-r--r-- | kittybox-rs/src/lib.rs | 71 | ||||
-rw-r--r-- | kittybox-rs/src/main.rs | 129 | ||||
-rw-r--r-- | kittybox-rs/src/media/mod.rs | 7 | ||||
-rw-r--r-- | kittybox-rs/src/micropub/mod.rs | 13 |
6 files changed, 165 insertions, 244 deletions
diff --git a/kittybox-rs/src/frontend/onboarding.rs b/kittybox-rs/src/frontend/onboarding.rs index 08a05ee..e9eceb2 100644 --- a/kittybox-rs/src/frontend/onboarding.rs +++ b/kittybox-rs/src/frontend/onboarding.rs @@ -145,3 +145,10 @@ pub async fn post<D: Storage + 'static>( } } } + +pub fn router<S: Storage + 'static>(database: S, http: reqwest::Client) -> axum::routing::MethodRouter { + axum::routing::get(get) + .post(post::<S>) + .layer(axum::Extension(database)) + .layer(axum::Extension(http)) +} diff --git a/kittybox-rs/src/index.html b/kittybox-rs/src/index.html deleted file mode 100644 index 1fc2a96..0000000 --- a/kittybox-rs/src/index.html +++ /dev/null @@ -1,182 +0,0 @@ -<html> - <head> - <meta charset="utf-8"> - <title>Kittybox-Micropub debug client</title> - <style type="text/css"> - * { - box-sizing: border-box; - } - :root { - font-family: sans-serif; - } - body { - margin: 0; - } - body > main { - margin: auto; - max-width: 1024px; - } - h1.header { - margin-top: 0.75em; - text-align: center; - } - fieldset + fieldset, fieldset + input, section + section, section + fieldset { - margin-top: 0.75em; - } - input[type="submit"] { - margin-left: auto; - display: block; - } - form > fieldset > section > label { - width: 100%; - display: block; - } - form > fieldset > section > input, form > fieldset > section > textarea { - width: 100%; - } - textarea { - min-height: 10em; - } - </style> - <script type="module"> - const form = document.getElementById("micropub"); - const channel_select_radio = document.getElementById("select_channels"); - channel_select_radio.onclick = async () => { - const channels = await query_channels() - if (channels !== undefined) { - populate_channel_list(channels) - } - } - const no_channel_radio = document.getElementById("no_channel"); - no_channel_radio.onclick = () => { - document.getElementById("channels").style.display = "none"; - const channel_list = document.getElementById("channels_target") - channel_list.innerHTML = ""; - } - function construct_body(form) { - return { - type: ["h-entry"], - properties: { - content: [form.elements.content.value], - name: form.elements.name.value ? [form.elements.name.value] : undefined, - category: form.elements.category.value ? form.elements.category.value.split(",").map(val => val.trim()) : undefined, - channel: form.elements.channel_select.value ? Array.from(form.elements.channel).map(i => i.checked ? i.value : false).filter(i => i) : undefined - } - } - } - - async function query_channels() { - const response = await fetch(form.action + "?q=config", { - headers: { - "Authorization": `Bearer ${form.elements.access_token.value}` - } - }) - - const config = await response.json(); - - return config["channels"] - } - - function populate_channel_list(channels) { - document.getElementById("channels").style.display = "block"; - const channel_list = document.getElementById("channels_target") - channel_list.innerHTML = ""; - channels.forEach((channel) => { - const template = document.getElementById("channel_selector").content.cloneNode(true) - const input = template.querySelector("input") - const label = template.querySelector("label") - input.id = `channel_selector_option_${channel.uid}` - input.value = channel.uid - label.for = input.id - label.innerHTML = `<a href="${channel.uid}">${channel.name}</a>` - - channel_list.appendChild(template) - }) - } - - form.onsubmit = async (event) => { - event.preventDefault() - console.log(JSON.stringify(construct_body(form))) - try { - const response = await fetch(form.action, { - method: form.method, - headers: { - "Authorization": `Bearer ${form.elements.access_token.value}`, - "Content-Type": "application/json" - }, - body: JSON.stringify(construct_body(form)) - }) - if (response.status != 201 || response.status != 202) { - console.error(await response.json()); - } - if (response.headers.get("Location")) { - window.location.href = response.headers.get("Location"); - } - } catch (e) { - console.error(e) - } - } - </script> - </head> - <body> - <h1 class="header">Kittybox-Micropub debug client</h1> - - <main> - <p> - In a pinch? Lost your Micropub client, but need to make a quick announcement? - Worry not, the debug client has your back. <i>I just hope you have a spare Micropub token stored somewhere like I do...</i> - </p> - - <form action="/micropub" method="POST" id="micropub"> - <fieldset> - <legend>Authorization details</legend> - <section> - <label for="access_token">Access token:</label> - <input id="access_token" name="access_token" type="password"> - - <p><a href="https://gimme-a-token.5eb.nl/" target="_blank">Get an access token (will open in a new tab)</a></p> - </section> - </fieldset> - <fieldset> - <legend>Post details:</legend> - <section> - <label for="name">Name (leave blank for an unnamed post):</label> - <input id="name" type="text"> - </section> - <section> - <label for="content">Content:</label> - <textarea id="content" placeholder="Your post's text goes here"></textarea> - </section> - <section> - <label for="category">Categories (separeted by commas):</label> - <input id="category" type="text"> - </section> - <fieldset> - <legend>Channels</legend> - <section> - <input type="radio" id="no_channel" name="channel_select" checked value=""> - <label for="no_channel">Default channel only</label> - </section> - - <section> - <input type="radio" id="select_channels" name="channel_select" value="on"> - <label for="select_channels">Select channels manually</label> - </section> - - <fieldset id="channels" style="display: none"> - <legend>Available channels:</legend> - <template id="channel_selector"> - <section> - <input type="checkbox" name="channel" id="" value=""> - <label for=""></label> - </section> - </template> - <div id="channels_target"></div> - </fieldset> - </fieldset> - </fieldset> - <input type="submit"> - </form> - </main> - </body> -</html> \ No newline at end of file diff --git a/kittybox-rs/src/lib.rs b/kittybox-rs/src/lib.rs index c1683d9..3f25689 100644 --- a/kittybox-rs/src/lib.rs +++ b/kittybox-rs/src/lib.rs @@ -7,5 +7,74 @@ pub mod frontend; pub mod tokenauth; pub mod media; pub mod micropub; +pub mod indieauth; -pub static MICROPUB_CLIENT: &[u8] = include_bytes!("./index.html"); +pub mod companion { + use axum::{ + extract::{Extension, Path}, + response::IntoResponse + }; + + #[derive(Debug, Clone, Copy)] + struct Resource { + data: &'static [u8], + mime: &'static str + } + + type ResourceTable = std::sync::Arc<std::collections::HashMap<&'static str, Resource>>; + + #[tracing::instrument] + async fn map_to_static( + Path(name): Path<String>, + Extension(resources): Extension<ResourceTable> + ) -> impl IntoResponse { + tracing::debug!("Searching for {} in the resource table...", name); + if let Some(res) = resources.get(name.as_str()) { + (axum::http::StatusCode::OK, + [("Content-Type", res.mime)], + res.data) + } else { + #[cfg(debug_assertions)] + tracing::error!("Not found"); + (axum::http::StatusCode::NOT_FOUND, + [("Content-Type", "text/plain")], + "Not found. Sorry.".as_bytes()) + } + } + + pub fn router() -> axum::Router { + let resources = { + let mut map = std::collections::HashMap::new(); + + macro_rules! register_resource { + ($prefix:literal, ($filename:literal, $mime:literal)) => {{ + map.insert($filename, Resource { + data: include_bytes!(concat!($prefix, $filename)), + mime: $mime + }) + }}; + ($prefix:literal, ($filename:literal, $mime:literal), $( ($f:literal, $m:literal) ),+) => {{ + register_resource!($prefix, ($filename, $mime)); + register_resource!($prefix, $(($f, $m)),+); + }}; + } + + register_resource! { + "../companion-lite/", + ("index.html", "text/html; charset=\"utf-8\""), + ("main.js", "text/javascript"), + ("micropub_api.js", "text/javascript"), + ("style.css", "text/css") + }; + + std::sync::Arc::new(map) + }; + + axum::Router::new() + .route( + "/:filename", + axum::routing::get(map_to_static) + .layer(Extension(resources)) + ) + } +} diff --git a/kittybox-rs/src/main.rs b/kittybox-rs/src/main.rs index 50c0ca5..59c3e69 100644 --- a/kittybox-rs/src/main.rs +++ b/kittybox-rs/src/main.rs @@ -110,11 +110,55 @@ async fn main() { kittybox::media::storage::file::FileStore::new(path) }; - let svc = axum::Router::new() + // This code proves that different components of Kittybox can + // be split up without hurting the app + // + // If needed, some features could be omitted from the binary + // or just not spun up in the future + // + // For example, the frontend code could run spearately from + // Micropub and only have read access to the database folder + let frontend = axum::Router::new() .route( "/", - axum::routing::get(kittybox::frontend::homepage::<FileStorage>), - ) + axum::routing::get(kittybox::frontend::homepage::<FileStorage>) + .layer(axum::Extension(database.clone()))) + .route("/.kittybox/static/:path", axum::routing::get(kittybox::frontend::statics)) + .fallback( + axum::routing::get(kittybox::frontend::catchall::<FileStorage>) + .layer(axum::Extension(database.clone()))); + + // Onboarding is a bit of a special case. One might argue that + // the onboarding makes Kittybox a monolith. This is wrong. + // The "onboarding receiver" doesn't need any code from the + // onboarding form - they're grouped in a single module for + // convenience only, since modifying one usually requires + // updating the other to match. + // + // For example, this "router" just groups two separate methods + // in one request, because logically they live in the same + // subtree. But one could manually construct only one but not + // the other, to receive a "frontend-only" application. Of + // course, in this scenario, one must employ a reverse proxy + // to distinguish between GET and POST requests to the same + // path, and route them to the correct set of endpoints with + // write access. + let onboarding = axum::Router::new() + .route("/.kittybox/onboarding", kittybox::frontend::onboarding::router( + database.clone(), http.clone() + )); + + let micropub = axum::Router::new() + .route("/.kittybox/micropub", kittybox::micropub::router(database.clone(), http.clone())) + .nest("/.kittybox/micropub/client", kittybox::companion::router()); + + let media = axum::Router::new() + .nest("/.kittybox/media", kittybox::media::router(blobstore).layer(axum::Extension(http))); + + /*let indieauth = axum::Router::new() + .nest("/.kittybox/indieauth", kittybox::indieauth::router());*/ + + let technical = axum::Router::new() .route( "/.kittybox/coffee", axum::routing::get(|| async { @@ -127,71 +171,34 @@ async fn main() { }), ) .route( - "/.kittybox/onboarding", - axum::routing::get(kittybox::frontend::onboarding::get) - .post(kittybox::frontend::onboarding::post::<FileStorage>), - ) - .route( - "/.kittybox/micropub", - axum::routing::get(kittybox::micropub::query::<FileStorage>) - .post(kittybox::micropub::post::<FileStorage>) - .layer( - tower_http::cors::CorsLayer::new() - .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) - .allow_origin(tower_http::cors::Any), - ), - ) - .route( - "/.kittybox/micropub/client", - axum::routing::get(|| { - std::future::ready(axum::response::Html(kittybox::MICROPUB_CLIENT)) - }), - ) - .route( "/.kittybox/health", - axum::routing::get(|| async { + axum::routing::get( + |axum::Extension(db): axum::Extension<FileStorage>| async move { // TODO health-check the database "OK" - }), + } + ) + .layer(axum::Extension(database)) ) .route( "/.kittybox/metrics", axum::routing::get(|| async { todo!() }), - ) - .nest( - "/.kittybox/media", - axum::Router::new() - .route( - "/", - axum::routing::get(|| async { todo!() }) - .post( - kittybox::media::upload::<kittybox::media::FileStore> - ), - ) - .route("/uploads/*file", axum::routing::get( - kittybox::media::serve::<kittybox::media::FileStore> - )), - ) - .route( - "/.kittybox/static/:path", - axum::routing::get(kittybox::frontend::statics), - ) - .fallback(axum::routing::get( - kittybox::frontend::catchall::<FileStorage>, - )) - .layer(axum::Extension(database)) - .layer(axum::Extension(http)) - .layer(axum::Extension(kittybox::tokenauth::TokenEndpoint( - token_endpoint, - ))) - .layer(axum::Extension(blobstore)) - .layer( - tower::ServiceBuilder::new() - .layer(tower_http::trace::TraceLayer::new_for_http()) - .into_inner(), ); - // A little dance to turn a potential file descriptor into a guaranteed async network socket + let svc = axum::Router::new() + .merge(frontend) + .merge(onboarding) + .merge(micropub) + .merge(media) + //.merge(indieauth) + .merge(technical) + .layer(axum::Extension(kittybox::tokenauth::TokenEndpoint(token_endpoint))) + .layer(tower::ServiceBuilder::new() + .layer(tower_http::trace::TraceLayer::new_for_http()) + .into_inner()); + + // A little dance to turn a potential file descriptor into + // a guaranteed async network socket let tcp_listener: std::net::TcpListener = { let mut listenfd = listenfd::ListenFd::from_env(); @@ -200,8 +207,8 @@ async fn main() { } else { std::net::TcpListener::bind(listen_at).unwrap() }; - // Set the socket to non-blocking so tokio can work with it properly - // This is the async magic + // Set the socket to non-blocking so tokio can poll it + // properly -- this is the async magic! tcp_listener.set_nonblocking(true).unwrap(); tcp_listener diff --git a/kittybox-rs/src/media/mod.rs b/kittybox-rs/src/media/mod.rs index 4a253d0..b64929d 100644 --- a/kittybox-rs/src/media/mod.rs +++ b/kittybox-rs/src/media/mod.rs @@ -98,3 +98,10 @@ pub async fn serve<S: MediaStore>( } } } + +pub fn router<S: MediaStore>(blobstore: S) -> axum::Router { + axum::Router::new() + .route("/", axum::routing::post(upload::<S>)) + .route("/uploads/*file", axum::routing::get(serve::<S>)) + .layer(axum::Extension(blobstore)) +} diff --git a/kittybox-rs/src/micropub/mod.rs b/kittybox-rs/src/micropub/mod.rs index 3328597..d0aeae0 100644 --- a/kittybox-rs/src/micropub/mod.rs +++ b/kittybox-rs/src/micropub/mod.rs @@ -596,6 +596,19 @@ pub async fn query<D: Storage>( } } +pub fn router<S: Storage + 'static>(storage: S, http: reqwest::Client) -> axum::routing::MethodRouter { + axum::routing::get(query::<S>) + .post(post::<S>) + .layer(tower_http::cors::CorsLayer::new() + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + ]) + .allow_origin(tower_http::cors::Any)) + .layer(axum::Extension(storage)) + .layer(axum::Extension(http)) +} + #[cfg(test)] #[allow(dead_code)] impl MicropubQuery { |