diff --git a/CHANGELOG.md b/CHANGELOG.md index b63141d..615fb94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Added + +- **HTML viewer** — `.html` and `.htm` files now show a "Rendered" toolbar button that renders the file in an inline iframe. A new path-based raw endpoint (`GET /api/v1/raw/{source}/{*path}`) is used so that relative assets (images, CSS) resolve correctly as sibling requests on the same endpoint. + --- ## [0.7.4] - 2026-04-23 diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 6189c83..95a0c1d 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -279,6 +279,7 @@ pub fn build_router(state: Arc) -> Router { .route("/api/v1/tree", get(routes::list_dir)) .route("/api/v1/tree/expand", get(routes::expand_tree)) .route("/api/v1/raw", get(routes::get_raw)) + .route("/api/v1/raw/{source}/{*path}", get(routes::get_raw_path)) .route("/api/v1/view", get(routes::get_view)) .route("/api/v1/links", post(routes::post_link)) .route("/api/v1/links/{code}", get(routes::get_link)) diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index 2319949..ab9dcfd 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -20,7 +20,7 @@ pub use context::{context_batch, get_context}; pub use errors::get_errors; pub use file::{get_file, list_files}; pub use links::{get_link, post_link}; -pub use raw::get_raw; +pub use raw::{get_raw, get_raw_path}; pub use recent::{get_recent, stream_recent}; pub use search::search; pub use session::{create_session, delete_session}; diff --git a/crates/server/src/routes/raw.rs b/crates/server/src/routes/raw.rs index 6fe41ec..a486dca 100644 --- a/crates/server/src/routes/raw.rs +++ b/crates/server/src/routes/raw.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use axum::{ body::Body, - extract::{Query, State}, + extract::{Path as AxumPath, Query, State}, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; @@ -474,6 +474,53 @@ async fn serve_archive_member( .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) } +/// GET /api/v1/raw/{source}/{*path} +/// +/// Path-based variant of get_raw. Source and file path are URL path segments +/// rather than query parameters, so the browser resolves relative URLs in HTML +/// documents (images, CSS, etc.) to sibling paths on the same endpoint. +/// Auth: bearer/cookie only (no link_code support). +pub async fn get_raw_path( + State(state): State>, + headers: HeaderMap, + AxumPath((source, path)): AxumPath<(String, String)>, +) -> Response { + if let Err(s) = check_auth(&state, &headers) { + return s.into_response(); + } + + let (_, canonical_full) = match super::resolve_source_path(&state, &source, &path) { + Ok(p) => p, + Err(s) => return s.into_response(), + }; + + let file_size = match tokio::fs::metadata(&canonical_full).await { + Ok(m) => m.len(), + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + let mime = mime_guess::from_path(&canonical_full).first_or_octet_stream(); + let display_filename = canonical_full + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .replace('"', ""); + + let file = match File::open(&canonical_full).await { + Ok(f) => f, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime.essence_str()) + .header(header::CONTENT_LENGTH, file_size.to_string()) + .header(header::CONTENT_DISPOSITION, format!("inline; filename=\"{display_filename}\"")) + .body(body) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + #[cfg(test)] mod tests { use super::parse_byte_range; diff --git a/web/src/lib/FileViewer.svelte b/web/src/lib/FileViewer.svelte index fa59ff6..01587d1 100644 --- a/web/src/lib/FileViewer.svelte +++ b/web/src/lib/FileViewer.svelte @@ -167,6 +167,15 @@ // Detect if file is RTF (check member extension for archive members) $: isRtf = (archivePath ?? path).toLowerCase().endsWith('.rtf'); + // Detect if file is HTML + $: isHtml = (archivePath ?? path).toLowerCase().endsWith('.html') || (archivePath ?? path).toLowerCase().endsWith('.htm'); + + // Path-based raw URL for HTML iframe: encodes each path segment so the browser + // resolves relative asset URLs (images, CSS) as siblings on the same endpoint. + $: htmlInlineUrl = isHtml && !isArchiveMember + ? `/api/v1/raw/${encodeURIComponent(source)}/${rawInlinePath.split('/').map(encodeURIComponent).join('/')}` + : rawInlineUrl; + // Word wrap preference (default: false for code, true for text files) $: wordWrap = $profile.wordWrap ?? false; @@ -196,6 +205,13 @@ // RTF format preference $: rtfFormat = $profile.rtfFormat ?? false; + // HTML format preference + $: htmlFormat = $profile.htmlFormat ?? false; + + function toggleHtmlFormat() { + $profile.htmlFormat = !htmlFormat; + } + // RTF rendered HTML — rendered client-side via rtf.js (dynamically imported). let renderedRtf = ''; let rtfFetchedForPath = ''; @@ -690,6 +706,11 @@ {rtfFormat ? 'Plain' : 'Formatted'} {/if} + {#if isHtml} + + {/if} {#if canOpenInExplorer}