Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ pub fn build_router(state: Arc<AppState>) -> 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))
Expand Down
2 changes: 1 addition & 1 deletion crates/server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
49 changes: 48 additions & 1 deletion crates/server/src/routes/raw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -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<Arc<AppState>>,
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;
Expand Down
34 changes: 33 additions & 1 deletion web/src/lib/FileViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -690,6 +706,11 @@
{rtfFormat ? 'Plain' : 'Formatted'}
</button>
{/if}
{#if isHtml}
<button class="toolbar-btn" on:click={toggleHtmlFormat} title="Toggle HTML rendering">
{htmlFormat ? 'Plain' : 'Rendered'}
</button>
{/if}
{#if canOpenInExplorer}
<button class="toolbar-btn explorer-btn download-icon-btn" style={explorerLaunching ? 'cursor: progress' : ''} on:click={openInExplorer} title="Open in Explorer">
<IconFolder />
Expand Down Expand Up @@ -842,7 +863,9 @@
{#if markdownTooLarge && markdownFormat}
<div class="no-content">File too large to render as markdown ({Math.round(rawContent.length / 1024)} KB &gt; {$maxMarkdownRenderKb} KB limit). Showing plain text.</div>
{/if}
{#if rtfFormat && isRtf}
{#if htmlFormat && isHtml}
<iframe src={htmlInlineUrl} title="HTML preview" class="html-iframe"></iframe>
{:else if rtfFormat && isRtf}
{#if renderedRtf}
<MarkdownViewer rendered={renderedRtf} />
{:else if rtfError}
Expand Down Expand Up @@ -987,6 +1010,15 @@
background: var(--bg);
}

.html-iframe {
width: 100%;
height: 80vh;
min-height: 400px;
border: none;
display: block;
background: white;
}

.dup-badge {
background: var(--badge-bg);
border: 1px solid var(--border);
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface UserProfile {
wordWrap?: boolean;
markdownFormat?: boolean;
rtfFormat?: boolean;
htmlFormat?: boolean;
sourceRoots?: Record<string, string>;
handlerInstalled?: boolean;
contextWindow?: number;
Expand Down