From 80061aad7ab5bbedb04dc8cce8a1e4743ef68f8e Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 6 Jan 2026 21:35:41 -0600 Subject: [PATCH] feat: add PGP public key page with multiple access endpoints - Add dedicated /pgp page with key viewer and download options - Support CLI-friendly endpoints (/publickey.asc, /pgp.asc, /.well-known/pgpkey.asc) - Detect user-agent to serve raw key to curl/wget or HTML to browsers - Add modal component for quick key access from homepage - Embed static key file in Rust assets for efficient serving --- src/assets.rs | 14 ++ src/main.rs | 82 +++++++++++ web/src/lib/components/AppWrapper.svelte | 10 +- web/src/lib/components/PgpKeyModal.svelte | 164 ++++++++++++++++++++++ web/src/lib/pgp/key-info.ts | 6 + web/src/routes/+page.svelte | 5 + web/src/routes/pgp/+page.server.ts | 18 +++ web/src/routes/pgp/+page.svelte | 159 +++++++++++++++++++++ web/static/publickey.asc | 52 +++++++ 9 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/components/PgpKeyModal.svelte create mode 100644 web/src/lib/pgp/key-info.ts create mode 100644 web/src/routes/pgp/+page.server.ts create mode 100644 web/src/routes/pgp/+page.svelte create mode 100644 web/static/publickey.asc diff --git a/src/assets.rs b/src/assets.rs index 476c5c9..fee2c08 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -55,6 +55,20 @@ fn serve_asset_by_path(path: &str) -> Response { } } +/// Get a static file from the embedded CLIENT_ASSETS. +/// +/// Static files are served from web/static/ and embedded at compile time. +/// +/// # Arguments +/// * `path` - Path to the file (e.g., "publickey.asc") +/// +/// # Returns +/// * `Some(&[u8])` - File content if file exists +/// * `None` - If file not found +pub fn get_static_file(path: &str) -> Option<&'static [u8]> { + CLIENT_ASSETS.get_file(path).map(|f| f.contents()) +} + /// Get prerendered error page HTML for a given status code. /// /// Error pages are prerendered by SvelteKit and embedded at compile time. diff --git a/src/main.rs b/src/main.rs index 88073d9..5ca6c0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -211,6 +211,11 @@ async fn main() { "/_app/{*path}", axum::routing::get(serve_embedded_asset).head(serve_embedded_asset), ) + .route("/pgp", axum::routing::get(handle_pgp_route)) + .route("/publickey.asc", axum::routing::get(serve_pgp_key)) + .route("/pgp.asc", axum::routing::get(serve_pgp_key)) + .route("/.well-known/pgpkey.asc", axum::routing::get(serve_pgp_key)) + .route("/keys", axum::routing::get(redirect_to_pgp)) } fn apply_middleware( @@ -409,6 +414,44 @@ fn accepts_html(headers: &HeaderMap) -> bool { true } +/// Determines if request prefers raw content (CLI tools) over HTML +fn prefers_raw_content(headers: &HeaderMap) -> bool { + // Check User-Agent for known CLI tools first (most reliable) + if let Some(ua) = headers.get(axum::http::header::USER_AGENT) { + if let Ok(ua_str) = ua.to_str() { + let ua_lower = ua_str.to_lowercase(); + if ua_lower.starts_with("curl/") + || ua_lower.starts_with("wget/") + || ua_lower.starts_with("httpie/") + || ua_lower.contains("curlie") + { + return true; + } + } + } + + // Check Accept header - if it explicitly prefers text/html, serve HTML + if let Some(accept) = headers.get(axum::http::header::ACCEPT) { + if let Ok(accept_str) = accept.to_str() { + // If text/html appears before */* in the list, they prefer HTML + if let Some(html_pos) = accept_str.find("text/html") { + if let Some(wildcard_pos) = accept_str.find("*/*") { + return html_pos > wildcard_pos; + } + // Has text/html but no */* → prefers HTML + return false; + } + // Has */* but no text/html → probably a CLI tool + if accept_str.contains("*/*") && !accept_str.contains("text/html") { + return true; + } + } + } + + // No Accept header → assume browser (safer default) + false +} + fn serve_error_page(status: StatusCode) -> Response { let status_code = status.as_u16(); @@ -460,6 +503,45 @@ async fn health_handler(State(state): State>) -> impl IntoResponse } } +async fn serve_pgp_key() -> impl IntoResponse { + if let Some(content) = assets::get_static_file("publickey.asc") { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/pgp-keys"), + ); + headers.insert( + axum::http::header::CONTENT_DISPOSITION, + axum::http::HeaderValue::from_static("attachment; filename=\"publickey.asc\""), + ); + headers.insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("public, max-age=86400"), + ); + (StatusCode::OK, headers, content).into_response() + } else { + (StatusCode::NOT_FOUND, "PGP key not found").into_response() + } +} + +async fn redirect_to_pgp() -> impl IntoResponse { + axum::response::Redirect::permanent("/pgp") +} + +async fn handle_pgp_route( + State(state): State>, + headers: HeaderMap, + req: Request, +) -> Response { + if prefers_raw_content(&headers) { + // Serve raw .asc file for CLI tools + serve_pgp_key().await.into_response() + } else { + // Proxy to Bun for HTML page + isr_handler(State(state), req).await + } +} + async fn api_404_and_method_handler(req: Request) -> impl IntoResponse { let method = req.method(); let uri = req.uri(); diff --git a/web/src/lib/components/AppWrapper.svelte b/web/src/lib/components/AppWrapper.svelte index 43dbda4..fe60488 100644 --- a/web/src/lib/components/AppWrapper.svelte +++ b/web/src/lib/components/AppWrapper.svelte @@ -21,12 +21,12 @@
-{#if showThemeToggle} -
- -
-{/if}
+ {#if showThemeToggle} +
+ +
+ {/if} {#if children} {@render children()} {/if} diff --git a/web/src/lib/components/PgpKeyModal.svelte b/web/src/lib/components/PgpKeyModal.svelte new file mode 100644 index 0000000..94f704a --- /dev/null +++ b/web/src/lib/components/PgpKeyModal.svelte @@ -0,0 +1,164 @@ + + +{#if open} +
e.key === "Escape" && handleClose()} + role="presentation" + tabindex="-1" + transition:fade={{ duration: 200 }} + > + +
+{/if} diff --git a/web/src/lib/pgp/key-info.ts b/web/src/lib/pgp/key-info.ts new file mode 100644 index 0000000..886d712 --- /dev/null +++ b/web/src/lib/pgp/key-info.ts @@ -0,0 +1,6 @@ +export const PGP_KEY_METADATA = { + fingerprint: '211D 7157 249B F07D 81C8 B9DE C217 005C F3C0 0672', + keyId: 'C217005CF3C00672', + email: 'xevion@xevion.dev', + name: 'Ryan Walters', +} as const; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 6f84090..b3beb45 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,6 +1,7 @@ @@ -70,6 +72,7 @@ + + + + +
+

+ How to use this key +

+
+

+ Import this key into your GPG keyring to encrypt messages for me or verify my signatures: +

+
+
curl https://xevion.dev/pgp | gpg --import
+ +
+

+ You can also find this key on public keyservers by searching for the fingerprint above. +

+
+
+ + +
diff --git a/web/static/publickey.asc b/web/static/publickey.asc new file mode 100644 index 0000000..4b27f44 --- /dev/null +++ b/web/static/publickey.asc @@ -0,0 +1,52 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +JUK4SSLSxYQ= +=yHBH +-----END PGP PUBLIC KEY BLOCK-----