Skip to content

Commit fb49a68

Browse files
committed
chore: sharing of binary data
1 parent 21d10b5 commit fb49a68

File tree

4 files changed

+314
-19
lines changed

4 files changed

+314
-19
lines changed

crates/rostra-web-ui/assets/style.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,31 @@ emoji-picker {
729729
background: url('/assets/icons/image.svg') center/contain no-repeat;
730730
}
731731

732+
/* Rostra media with download fallback */
733+
.m-rostraMedia img {
734+
max-width: 100%;
735+
}
736+
737+
.m-rostraMedia__download {
738+
display: inline-flex;
739+
align-items: center;
740+
gap: 0.3em;
741+
color: var(--color-link);
742+
text-decoration: none;
743+
}
744+
745+
.m-rostraMedia__download:hover {
746+
text-decoration: underline;
747+
}
748+
749+
.m-rostraMedia__downloadIcon {
750+
display: inline-block;
751+
width: 1em;
752+
height: 1em;
753+
background: url('/assets/icons/download.svg') center/contain no-repeat;
754+
filter: invert(var(--invert));
755+
}
756+
732757
.m-postView__content.-missing {
733758
color: red;
734759
font-style: italic;

crates/rostra-web-ui/src/routes/content/filters.rs

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ use rostra_core::id::RostraId;
77

88
use crate::UiState;
99

10+
/// Escape HTML special characters for use in attributes and text
11+
fn escape_html(s: &str) -> String {
12+
s.chars()
13+
.flat_map(|c| match c {
14+
'&' => vec!['&', 'a', 'm', 'p', ';'],
15+
'<' => vec!['&', 'l', 't', ';'],
16+
'>' => vec!['&', 'g', 't', ';'],
17+
'"' => vec!['&', 'q', 'u', 'o', 't', ';'],
18+
'\'' => vec!['&', '#', '3', '9', ';'],
19+
c => vec![c],
20+
})
21+
.collect()
22+
}
23+
1024
/// Tracks what type of container we're in for proper Event::End handling
1125
#[derive(Clone)]
1226
enum ContainerKind {
@@ -127,8 +141,8 @@ where
127141

128142
/// Tracks the type of image transformation being applied
129143
enum ImageTransform<'s> {
130-
/// Regular rostra media link
131-
RostraMedia,
144+
/// Regular rostra media link - stores the URL and alt text
145+
RostraMedia(String, String),
132146
/// External embeddable media (YouTube, etc.) - stores the HTML and alt text
133147
EmbeddableMedia(String, String),
134148
/// Regular external image - stores the URL, link type, and alt text
@@ -166,19 +180,14 @@ where
166180

167181
async fn emit(&mut self, event: Event<'s>) -> Result<(), Self::Error> {
168182
match event {
169-
Event::Start(Container::Image(s, link_type), attr) => {
183+
Event::Start(Container::Image(s, link_type), _attr) => {
170184
if let Some(event_id) = UiState::extra_rostra_media_link(&s) {
171185
// Transform rostra-media: links to /ui/media/ URLs
172-
self.container_stack.push(Some(ImageTransform::RostraMedia));
173-
self.inner
174-
.emit(Event::Start(
175-
Container::Image(
176-
format!("/ui/media/{}/{}", self.author_id, event_id).into(),
177-
jotup::SpanLinkType::Inline,
178-
),
179-
attr,
180-
))
181-
.await
186+
let url = format!("/ui/media/{}/{}", self.author_id, event_id);
187+
self.container_stack
188+
.push(Some(ImageTransform::RostraMedia(url, String::new())));
189+
// Don't emit Start yet - we'll emit everything in End
190+
Ok(())
182191
} else {
183192
// External image - check if it's embeddable media
184193
if let Some(html) = super::maybe_embed_media_html(&s) {
@@ -214,9 +223,10 @@ where
214223
// If we're inside an image transformation, capture the alt text
215224
if let Some(Some(transform)) = self.container_stack.last_mut() {
216225
match transform {
217-
ImageTransform::RostraMedia => {
218-
// For rostra media, pass through the str
219-
self.inner.emit(Event::Str(s)).await
226+
ImageTransform::RostraMedia(_, alt) => {
227+
// Capture alt text for rostra media
228+
*alt = s.to_string();
229+
Ok(())
220230
}
221231
ImageTransform::EmbeddableMedia(_, alt) => {
222232
// Capture alt text, skip emitting the str for now
@@ -236,8 +246,42 @@ where
236246
Event::End => {
237247
if let Some(Some(transform)) = self.container_stack.pop() {
238248
match transform {
239-
ImageTransform::RostraMedia => {
240-
// Just emit End for rostra media
249+
ImageTransform::RostraMedia(url, alt) => {
250+
// Render rostra media with download fallback for unsupported types
251+
let alt = alt.trim();
252+
let display_name = if alt.is_empty() { "media" } else { alt };
253+
254+
// Sanitize filename from alt text
255+
let filename: String = display_name
256+
.chars()
257+
.map(|c| {
258+
if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
259+
c
260+
} else {
261+
'-'
262+
}
263+
})
264+
.collect();
265+
266+
// Emit raw HTML with onerror fallback
267+
let url_escaped = escape_html(&url);
268+
let alt_escaped = escape_html(alt);
269+
let filename_escaped = escape_html(&filename);
270+
let display_escaped = escape_html(display_name);
271+
272+
let html = format!(
273+
r#"<span class="m-rostraMedia"><img src="{url_escaped}" alt="{alt_escaped}" onerror="this.parentElement.innerHTML='<a href=\'{url_escaped}\' download=\'{filename_escaped}\' class=\'m-rostraMedia__download\'><span class=\'m-rostraMedia__downloadIcon\'></span>{display_escaped}</a>'"/></span>"#
274+
);
275+
276+
self.inner
277+
.emit(Event::Start(
278+
Container::RawInline {
279+
format: "html".into(),
280+
},
281+
Attributes::new(),
282+
))
283+
.await?;
284+
self.inner.emit(Event::Str(html.into())).await?;
241285
self.inner.emit(Event::End).await
242286
}
243287
ImageTransform::EmbeddableMedia(html, alt) => {

crates/rostra-web-ui/src/routes/content/tests.rs

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use std::str::FromStr;
22

3+
use jotup::r#async::AsyncRenderOutputExt;
34
use rostra_core::id::RostraId;
45

6+
use super::RostraRenderExt;
57
use crate::UiState;
68

79
#[test]
@@ -13,3 +15,227 @@ fn extract_rostra_id_link() {
1315
Some(RostraId::from_str("rse1okfyp4yj75i6riwbz86mpmbgna3f7qr66aj1njceqoigjabegy").unwrap())
1416
);
1517
}
18+
19+
/// Valid base32 test event ID (16 bytes = 26 base32 characters)
20+
const TEST_EVENT_ID: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAA";
21+
22+
#[test]
23+
fn extract_rostra_media_link() {
24+
assert_eq!(
25+
UiState::extra_rostra_media_link(&format!("rostra-media:{TEST_EVENT_ID}")),
26+
Some(rostra_core::ShortEventId::from_str(TEST_EVENT_ID).unwrap())
27+
);
28+
assert_eq!(UiState::extra_rostra_media_link("not-a-media-link"), None);
29+
}
30+
31+
/// Helper to render djot content with image filter only
32+
async fn render_with_images(content: &str, author_id: RostraId) -> String {
33+
let renderer = jotup::html::tokio::Renderer::default().rostra_images(author_id);
34+
35+
let out = renderer
36+
.render_into_document(content)
37+
.await
38+
.expect("Rendering failed");
39+
40+
String::from_utf8(out.into_inner()).expect("valid utf8")
41+
}
42+
43+
/// Helper to render djot content with code block filter only
44+
async fn render_with_prism(content: &str) -> String {
45+
let renderer = jotup::html::tokio::Renderer::default().prism_code_blocks();
46+
47+
let out = renderer
48+
.render_into_document(content)
49+
.await
50+
.expect("Rendering failed");
51+
52+
String::from_utf8(out.into_inner()).expect("valid utf8")
53+
}
54+
55+
#[tokio::test]
56+
async fn rostra_media_renders_with_download_fallback() {
57+
let author_id =
58+
RostraId::from_str("rse1okfyp4yj75i6riwbz86mpmbgna3f7qr66aj1njceqoigjabegy").unwrap();
59+
let content = format!("![media](rostra-media:{TEST_EVENT_ID})");
60+
61+
let html = render_with_images(&content, author_id).await;
62+
63+
// Should contain the wrapper span
64+
assert!(html.contains("m-rostraMedia"), "Missing wrapper class");
65+
66+
// Should contain the image with correct URL
67+
assert!(
68+
html.contains(&format!(
69+
"/ui/media/rse1okfyp4yj75i6riwbz86mpmbgna3f7qr66aj1njceqoigjabegy/{TEST_EVENT_ID}"
70+
)),
71+
"Missing media URL"
72+
);
73+
74+
// Should contain onerror handler for download fallback
75+
assert!(html.contains("onerror="), "Missing onerror handler");
76+
assert!(
77+
html.contains("m-rostraMedia__download"),
78+
"Missing download class in fallback"
79+
);
80+
assert!(
81+
html.contains("m-rostraMedia__downloadIcon"),
82+
"Missing download icon class"
83+
);
84+
}
85+
86+
#[tokio::test]
87+
async fn rostra_media_uses_alt_text_in_fallback() {
88+
let author_id =
89+
RostraId::from_str("rse1okfyp4yj75i6riwbz86mpmbgna3f7qr66aj1njceqoigjabegy").unwrap();
90+
let content = format!("![my cool file](rostra-media:{TEST_EVENT_ID})");
91+
92+
let html = render_with_images(&content, author_id).await;
93+
94+
// Should use alt text in the download link
95+
assert!(
96+
html.contains("my cool file"),
97+
"Missing alt text in fallback"
98+
);
99+
100+
// Should sanitize filename (spaces become dashes)
101+
assert!(
102+
html.contains("my-cool-file"),
103+
"Filename not sanitized correctly"
104+
);
105+
}
106+
107+
#[tokio::test]
108+
async fn rostra_media_empty_alt_uses_default() {
109+
let author_id =
110+
RostraId::from_str("rse1okfyp4yj75i6riwbz86mpmbgna3f7qr66aj1njceqoigjabegy").unwrap();
111+
let content = format!("![](rostra-media:{TEST_EVENT_ID})");
112+
113+
let html = render_with_images(&content, author_id).await;
114+
115+
// Should use "media" as default display name
116+
assert!(
117+
html.contains(">media</a>"),
118+
"Missing default 'media' text in fallback"
119+
);
120+
}
121+
122+
#[tokio::test]
123+
async fn external_image_gets_lazy_loading() {
124+
let author_id =
125+
RostraId::from_str("rse1okfyp4yj75i6riwbz86mpmbgna3f7qr66aj1njceqoigjabegy").unwrap();
126+
let content = "![alt text](https://example.com/image.png)";
127+
128+
let html = render_with_images(content, author_id).await;
129+
130+
// Should have lazyload wrapper
131+
assert!(
132+
html.contains("lazyload-wrapper"),
133+
"Missing lazyload wrapper"
134+
);
135+
136+
// Should have lazyload message
137+
assert!(
138+
html.contains("lazyload-message"),
139+
"Missing lazyload message"
140+
);
141+
142+
// Should use data-src instead of src for lazy loading
143+
assert!(
144+
html.contains("data-src=\"https://example.com/image.png\""),
145+
"Missing data-src attribute"
146+
);
147+
148+
// Should include the alt text in the load message
149+
assert!(
150+
html.contains("alt text"),
151+
"Missing alt text in load message"
152+
);
153+
}
154+
155+
#[tokio::test]
156+
async fn youtube_embed_gets_lazy_loading() {
157+
let author_id =
158+
RostraId::from_str("rse1okfyp4yj75i6riwbz86mpmbgna3f7qr66aj1njceqoigjabegy").unwrap();
159+
let content = "![video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
160+
161+
let html = render_with_images(content, author_id).await;
162+
163+
// Should have lazyload wrapper
164+
assert!(
165+
html.contains("lazyload-wrapper"),
166+
"Missing lazyload wrapper"
167+
);
168+
169+
// Should have video-specific lazyload message
170+
assert!(
171+
html.contains("lazyload-message -video"),
172+
"Missing video lazyload message"
173+
);
174+
175+
// Should create an iframe with youtube embed URL
176+
assert!(
177+
html.contains("youtube.com/embed/dQw4w9WgXcQ"),
178+
"Missing youtube embed URL"
179+
);
180+
181+
// Should use data-src for lazy loading
182+
assert!(
183+
html.contains("data-src=\"https://www.youtube.com/embed/"),
184+
"Missing data-src on iframe"
185+
);
186+
}
187+
188+
#[tokio::test]
189+
async fn youtu_be_short_url_works() {
190+
let author_id =
191+
RostraId::from_str("rse1okfyp4yj75i6riwbz86mpmbgna3f7qr66aj1njceqoigjabegy").unwrap();
192+
let content = "![](https://youtu.be/dQw4w9WgXcQ)";
193+
194+
let html = render_with_images(content, author_id).await;
195+
196+
// Should create an iframe with youtube embed URL
197+
assert!(
198+
html.contains("youtube.com/embed/dQw4w9WgXcQ"),
199+
"Missing youtube embed URL for short URL"
200+
);
201+
}
202+
203+
#[tokio::test]
204+
async fn code_block_gets_prism_classes() {
205+
let content = "```rust\nfn main() {}\n```";
206+
207+
let html = render_with_prism(content).await;
208+
209+
// Should have language class on code element
210+
assert!(
211+
html.contains("language-rust"),
212+
"Missing language-rust class"
213+
);
214+
}
215+
216+
#[tokio::test]
217+
async fn code_block_unknown_language() {
218+
let content = "```\nplain code\n```";
219+
220+
let html = render_with_prism(content).await;
221+
222+
// Should still render as code block
223+
assert!(html.contains("<code"), "Missing code element");
224+
}
225+
226+
#[tokio::test]
227+
async fn inline_code_not_affected_by_prism() {
228+
let content = "Some `inline code` here";
229+
230+
let html = render_with_prism(content).await;
231+
232+
// Inline code should not get language class
233+
assert!(
234+
!html.contains("language-"),
235+
"Inline code should not have language class"
236+
);
237+
assert!(
238+
html.contains("<code>inline code</code>"),
239+
"Missing inline code"
240+
);
241+
}

crates/rostra-web-ui/src/routes/timeline.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ impl UiState {
360360
(PreEscaped(r#"
361361
window.insertMediaSyntax = function(eventId) {
362362
const textarea = document.querySelector('.m-newPostForm__content');
363-
const syntax = '![](rostra-media:' + eventId + ')';
363+
const syntax = '![media](rostra-media:' + eventId + ')';
364364
365365
if (textarea) {
366366
const start = textarea.selectionStart;

0 commit comments

Comments
 (0)