Fix the open bugs: read state, Unread tab, feed errors, theme button, log button, relative images
- Opening an item stays read: a list refresh that crossed with the write no longer puts the unread dot back (#16). - On the Unread tab the item you were reading goes when you move to the next (#17). - Feed errors mark the feed with a red ! instead of a toast per failure (#20). - The theme is chosen in Settings only (#15). - The server leaves the Log button out of a non-admin's page, so it no longer flashes (#29). - Relative images and links in a post resolve against the post's link (#28). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
48
src/web.rs
48
src/web.rs
@@ -471,8 +471,21 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
async fn index() -> Html<&'static str> {
|
||||
Html(include_str!("../web/index.html"))
|
||||
const INDEX: &str = include_str!("../web/index.html");
|
||||
const LOG_BUTTON: &str = r#"<button id="logs" "#;
|
||||
|
||||
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
|
||||
/// script instead showed it for a moment on every load, until /api/me answered.
|
||||
async fn index(user: crate::db::User) -> Html<std::borrow::Cow<'static, str>> {
|
||||
Html(page_for(user.is_admin))
|
||||
}
|
||||
|
||||
fn page_for(admin: bool) -> std::borrow::Cow<'static, str> {
|
||||
if admin {
|
||||
INDEX.into()
|
||||
} else {
|
||||
INDEX.replacen(LOG_BUTTON, r#"<button id="logs" hidden "#, 1).into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -795,6 +808,24 @@ impl IntoResponse for ApiError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_relative_image_resolves_against_the_post() {
|
||||
let mut b = ammonia::Builder::new();
|
||||
let html = r#"<img src="images/a.webp"><a href="/about">x</a><script>bad()</script>"#;
|
||||
let out = clean_description(&mut b, html, Some("https://example.com/2026/04/post/"));
|
||||
assert!(out.contains(r#"src="https://example.com/2026/04/post/images/a.webp""#), "{out}");
|
||||
assert!(out.contains(r#"href="https://example.com/about""#), "{out}");
|
||||
assert!(!out.contains("bad()"), "{out}");
|
||||
assert!(clean_description(&mut b, html, None).contains(r#"src="images/a.webp""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_admin_is_sent_the_log_button() {
|
||||
// If the markup drifts from LOG_BUTTON, replacen matches nothing and says nothing.
|
||||
assert!(page_for(false).contains(r#"<button id="logs" hidden "#));
|
||||
assert!(!page_for(true).contains(r#"id="logs" hidden"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_with_a_credential_is_never_popular() {
|
||||
let f = |url: &str| crate::config::Feed {
|
||||
@@ -974,13 +1005,24 @@ fn entry_page(
|
||||
sanitizer.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
|
||||
for row in &mut rows {
|
||||
if let Some(d) = &row.description {
|
||||
row.description = Some(sanitizer.clean(d).to_string());
|
||||
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
|
||||
}
|
||||
}
|
||||
let total = db.count_in(user_id, feed, filter, search)?;
|
||||
Ok(Json(EntryPage { total, entries: rows }))
|
||||
}
|
||||
|
||||
/// A relative `src` or `href` in a post means relative to the post, not to ipx: The
|
||||
/// Observation Deck's `images/37k-a-day-bro.webp` came up as a broken image.
|
||||
fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<&str>) -> String {
|
||||
let base = link.and_then(|l| url::Url::parse(l).ok());
|
||||
sanitizer.url_relative(match base {
|
||||
Some(b) => ammonia::UrlRelative::RewriteWithBase(b),
|
||||
None => ammonia::UrlRelative::PassThrough,
|
||||
});
|
||||
sanitizer.clean(html).to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NewFeed {
|
||||
url: String,
|
||||
|
||||
Reference in New Issue
Block a user