about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--kittybox-rs/companion-lite/index.html77
-rw-r--r--kittybox-rs/companion-lite/main.js70
-rw-r--r--kittybox-rs/companion-lite/micropub_api.js43
-rw-r--r--kittybox-rs/companion-lite/style.css47
-rw-r--r--kittybox-rs/src/frontend/onboarding.rs7
-rw-r--r--kittybox-rs/src/index.html182
-rw-r--r--kittybox-rs/src/lib.rs71
-rw-r--r--kittybox-rs/src/main.rs129
-rw-r--r--kittybox-rs/src/media/mod.rs7
-rw-r--r--kittybox-rs/src/micropub/mod.rs13
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 {