diff options
-rw-r--r-- | kittybox-rs/companion-lite/index.html | 77 | ||||
-rw-r--r-- | kittybox-rs/companion-lite/main.js | 70 | ||||
-rw-r--r-- | kittybox-rs/companion-lite/micropub_api.js | 43 | ||||
-rw-r--r-- | kittybox-rs/companion-lite/style.css | 47 | ||||
-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 |
10 files changed, 402 insertions, 244 deletions
diff --git a/kittybox-rs/companion-lite/index.html b/kittybox-rs/companion-lite/index.html new file mode 100644 index 0000000..b643ba2 --- /dev/null +++ b/kittybox-rs/companion-lite/index.html @@ -0,0 +1,77 @@ +<html> + <head> + <meta charset="utf-8"> + <title>Kittybox-Micropub debug client</title> + <link rel="stylesheet" href="./style.css"> + <script type="module" src="./main.js"></script> + </head> + <body> + <noscript> + <h1 class="header">Kittybox Companion (Lite)</h1> + <p>I'm sorry, Kittybox Companion requires JavaScript to work.</p> + + <p>This is a requirement due to multiple interactive features present in Kittybox, such as support for multiple-entry form fields, interactive login sequence and more.</p> + + <p>However, the Micropub standard is extremely flexible, and if you happen to have a token, you can publish articles, notes, likes, follows and more by sending requests directly to the Micropub endpoint.</p> + + <p><a href="https://micropub.spec.indieweb.org/">The Micropub spec is defined here.</a> Good luck!</p> + </noscript> + + <div class="view" id="unauthorized" style="display:none"> + + </div> + + <div class="view" id="authorized" style="display:none"> + <form action="/.kittybox/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"> + </div> + </main> + </body> +</html> diff --git a/kittybox-rs/companion-lite/main.js b/kittybox-rs/companion-lite/main.js new file mode 100644 index 0000000..da7e6e1 --- /dev/null +++ b/kittybox-rs/companion-lite/main.js @@ -0,0 +1,70 @@ +import { query_channels, submit } from "./micropub_api.js"; + +function get_token() { + return form.elements.access_token.value +} + +const form = document.getElementById("micropub"); +const channel_select_radio = document.getElementById("select_channels"); + +channel_select_radio.onclick = async () => { + const channels = await query_channels(form.action, get_token()) + 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 + } + } +} + +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() + const mf2 = construct_body(form); + console.log(JSON.stringify(mf2)); + try { + submit(form.action, get_token(), mf2) + } catch (e) { + // TODO show errors to user + + return + } + form.clear() +} + +document.getElementById("authorized").style.display = "block"; +// Local Variables: +// js-indent-level: 4 +// End: diff --git a/kittybox-rs/companion-lite/micropub_api.js b/kittybox-rs/companion-lite/micropub_api.js new file mode 100644 index 0000000..402c075 --- /dev/null +++ b/kittybox-rs/companion-lite/micropub_api.js @@ -0,0 +1,43 @@ +export async function query_channels(endpoint, token) { + const response = await fetch(endpoint + "?q=config", { + headers: { + "Authorization": `Bearer ${get_token()}` + } + }) + + const config = await response.json(); + + return config["channels"] +} + +export async function submit(endpoint, token, mf2) { + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(mf2) + }) + + + if (response.status != 201 || response.status != 202) { + let err = await response.json(); + console.error("Micropub error!", err); + + return err; + } else { + return { + "location": response.headers.get("Location") + } + } + } catch (e) { + console.error("Network error!", e) + throw e + } +} + +// Local Variables: +// js-indent-level: 4 +// End: diff --git a/kittybox-rs/companion-lite/style.css b/kittybox-rs/companion-lite/style.css new file mode 100644 index 0000000..09ed398 --- /dev/null +++ b/kittybox-rs/companion-lite/style.css @@ -0,0 +1,47 @@ +* { + 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; +} 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 { |