From 9c0153ff7420ee94e69b22542739fa94ac609804 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 21 May 2026 16:20:08 -0700 Subject: [PATCH 01/23] Tighten sidebar and add quote icon --- src/render.rs | 22 ++++++++++++++++------ src/server.rs | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/render.rs b/src/render.rs index b0200e4..744edb9 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1074,7 +1074,7 @@ fn repost_action_with_quote( ) -> String { let menu_id = format!("quote-menu-{}", action.trim_matches('/').replace('/', "-")); format!( - r#"
Quote
"#, + r#"
{}Quote post
"#, html_escape::encode_double_quoted_attribute(action), html_escape::encode_double_quoted_attribute(csrf), if active { " active" } else { "" }, @@ -1086,7 +1086,9 @@ fn repost_action_with_quote( html_escape::encode_text(label), html_escape::encode_double_quoted_attribute(&menu_id), html_escape::encode_double_quoted_attribute(quote_href), - html_escape::encode_double_quoted_attribute(quote_href) + icon_svg("quote-post"), + html_escape::encode_double_quoted_attribute(quote_href), + icon_svg("quote-post") ) } @@ -1195,6 +1197,9 @@ fn icon_svg(icon: &str) -> &'static str { "repost" => { r#""# } + "quote-post" => { + r#""# + } "bookmark" => { r#""# } @@ -1529,7 +1534,7 @@ nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:fl nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline} nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} .nav-badge{display:inline-grid;place-items:center;min-width:1.25rem;height:1.25rem;border-radius:999px;padding:0 .35rem;background:var(--brand);color:var(--brand-text);font-size:.78rem;font-weight:800;line-height:1} -main{padding:1.25rem}.app-shell{width:min(100%,1180px);margin:0 auto;display:grid;grid-template-columns:220px minmax(0,640px) 260px;gap:1.25rem;align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:5rem;display:grid;gap:.75rem}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;gap:.25rem}.rail-nav a,.rail-nav button{width:100%;justify-content:flex-start}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:1180px;margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} +main{padding:1.25rem}.app-shell{width:min(100%,1180px);margin:0 auto;display:grid;grid-template-columns:220px minmax(0,640px) 260px;gap:1.25rem;align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:5rem;display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;gap:.15rem;width:max-content;max-width:100%;padding:.35rem}.rail-nav a,.rail-nav button{width:100%;justify-content:flex-start}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:1180px;margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} .page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:.85rem;box-shadow:0 1px 2px var(--shadow)} .page-header h1,.section-heading h1,.panel h1{margin:0;font-size:1.45rem;line-height:1.2}.panel h1+table,.panel h1+form,.panel h1+p,.panel h1+dl{margin-top:.85rem}.page-header p,.muted,.empty-state p{color:var(--muted);margin:.35rem 0 0}.section-heading{display:flex;justify-content:space-between;gap:1rem;align-items:baseline;margin-bottom:.8rem} .notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.notification-row[data-card-href]{cursor:pointer}.notification-row[data-card-href]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} @@ -1542,7 +1547,7 @@ nav button,.rail-nav button{border-color:transparent;background:transparent;colo .composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem}.file-control{display:inline-flex;align-items:center;gap:.6rem;margin:0;color:var(--link-strong);font-weight:700}.file-control input{max-width:15rem} .thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} .timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.post[data-card-href]{cursor:pointer}.post[data-card-href]:hover{border-color:var(--border-strong)}.post[data-card-href]:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px} -.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a,.quote-fallback{display:inline-flex;align-items:center;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a{width:100%}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-fallback{font-size:.88rem}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem} +.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem} .profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} @media (max-width:1100px){.app-shell{grid-template-columns:200px minmax(0,640px)}.right-rail{display:none}} @media (max-width:820px){.app-shell{grid-template-columns:minmax(0,680px)}.left-rail,.right-rail{display:none}.mobile-nav{display:flex}} @@ -1892,8 +1897,13 @@ mod tests { assert!(body.contains("data-repost-menu-button")); assert!(body.contains(r#"aria-haspopup="menu""#)); assert!(body.contains(r#"role="menu""#)); - assert!(body.contains(r#"href="/posts/42/quote">Quote post"#)); - assert!(body.contains(r#"class="quote-fallback" href="/posts/42/quote">Quote"#)); + assert!(body.contains(r#"role="menuitem" href="/posts/42/quote">"#)); + assert!(body.contains(r#"Quote post"#)); + assert!(body.contains( + r#"class="icon-button quote-fallback" href="/posts/42/quote" aria-label="Quote post" title="Quote post""# + )); + assert!(body.contains(r#"Quote post"#)); + assert!(!body.contains(r#">Quote"#)); } #[test] diff --git a/src/server.rs b/src/server.rs index 2d60d0c..f149f0f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3646,7 +3646,7 @@ mod tests { assert!( bob_home .body - .contains(r#"class="quote-fallback" href="/posts/1/quote""#) + .contains(r#"class="icon-button quote-fallback" href="/posts/1/quote" aria-label="Quote post" title="Quote post""#) ); assert!(bob_home.body.contains("data-repost-menu-button")); From 9e7762e229e47ec97ab2ddf80b72a4ccb5abd74a Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 21 May 2026 20:29:53 -0700 Subject: [PATCH 02/23] Bump RustPost to 0.1.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e954e94..936f8e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3135,7 +3135,7 @@ dependencies = [ [[package]] name = "rustpost" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index 9c29e13..83be5b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpost" -version = "0.1.4" +version = "0.1.5" edition = "2024" license = "MIT" description = "A single-binary self-hosted microblogging app" From 3133563db304b9ce311546ee3e4653afdc1d7687 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 21 May 2026 21:12:30 -0700 Subject: [PATCH 03/23] Balance desktop shell sidebar spacing --- src/render.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/render.rs b/src/render.rs index 744edb9..7ad38de 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1524,17 +1524,17 @@ pub fn error_page(status: StatusCode, message: &str) -> String { } const CSS: &str = r#" -:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;line-height:1.5;--bg:#f5f6f1;--surface:#fff;--surface-subtle:#fbfcfa;--surface-muted:#f4f5f2;--header-bg:rgba(255,255,255,.96);--text:#202124;--text-strong:#172017;--muted:#667064;--muted-strong:#59625a;--border:#dfe4dc;--border-strong:#b9c2b8;--link:#1f5f8b;--link-strong:#24445f;--brand:#163b2f;--brand-hover:#235544;--brand-text:#fff;--hover:#eef3f0;--focus:#93c5fd;--shadow:rgba(20,35,30,.04);--reply-border:#c8d8d0;--avatar-bg:#eef3f0;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4} +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;line-height:1.5;--bg:#f5f6f1;--surface:#fff;--surface-subtle:#fbfcfa;--surface-muted:#f4f5f2;--header-bg:rgba(255,255,255,.96);--text:#202124;--text-strong:#172017;--muted:#667064;--muted-strong:#59625a;--border:#dfe4dc;--border-strong:#b9c2b8;--link:#1f5f8b;--link-strong:#24445f;--brand:#163b2f;--brand-hover:#235544;--brand-text:#fff;--hover:#eef3f0;--focus:#93c5fd;--shadow:rgba(20,35,30,.04);--reply-border:#c8d8d0;--avatar-bg:#eef3f0;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4;--shell-side:240px;--shell-primary:640px;--shell-gap:1.25rem;--shell-max:1160px} :root[data-theme="dark"]{color-scheme:dark;--bg:#111827;--surface:#182231;--surface-subtle:#1d2939;--surface-muted:#233044;--header-bg:rgba(17,24,39,.96);--text:#eef4fb;--text-strong:#f8fafc;--muted:#c3cfdd;--muted-strong:#d4deea;--border:#344256;--border-strong:#596b83;--link:#8fc7ff;--link-strong:#badcff;--brand:#4f8fc7;--brand-hover:#6aa8df;--brand-text:#06111f;--hover:#243349;--focus:#fbbf24;--shadow:rgba(0,0,0,.26);--reply-border:#4f6680;--avatar-bg:#243349;--danger:#ffb4a2;--danger-strong:#ffd2c7;--danger-bg:#3a2020;--danger-border:#8f4d43;--success-bg:#163321;--success-border:#4c8a61;--media-bg:#0f172a} *{box-sizing:border-box}body{margin:0;min-width:320px;color:var(--text);background:var(--bg)}a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline} .site-header{position:sticky;top:0;z-index:10;background:var(--header-bg);border-bottom:1px solid var(--border);backdrop-filter:blur(8px)} -.header-inner{max-width:1180px;margin:0 auto;padding:.8rem 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} +.header-inner{max-width:var(--shell-max);margin:0 auto;padding:.8rem 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} .brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong)}.brand-mark{display:inline-grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--brand);color:var(--brand-text)} nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}nav a,nav button,.button-link{display:inline-flex;align-items:center;gap:.35rem;min-height:2.15rem;border-radius:7px;padding:.42rem .65rem;color:var(--link-strong);border:1px solid transparent;background:transparent} nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline} nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} .nav-badge{display:inline-grid;place-items:center;min-width:1.25rem;height:1.25rem;border-radius:999px;padding:0 .35rem;background:var(--brand);color:var(--brand-text);font-size:.78rem;font-weight:800;line-height:1} -main{padding:1.25rem}.app-shell{width:min(100%,1180px);margin:0 auto;display:grid;grid-template-columns:220px minmax(0,640px) 260px;gap:1.25rem;align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:5rem;display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;gap:.15rem;width:max-content;max-width:100%;padding:.35rem}.rail-nav a,.rail-nav button{width:100%;justify-content:flex-start}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:1180px;margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} +main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:5rem;display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;gap:.2rem;width:100%;padding:.35rem}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} .page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:.85rem;box-shadow:0 1px 2px var(--shadow)} .page-header h1,.section-heading h1,.panel h1{margin:0;font-size:1.45rem;line-height:1.2}.panel h1+table,.panel h1+form,.panel h1+p,.panel h1+dl{margin-top:.85rem}.page-header p,.muted,.empty-state p{color:var(--muted);margin:.35rem 0 0}.section-heading{display:flex;justify-content:space-between;gap:1rem;align-items:baseline;margin-bottom:.8rem} .notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.notification-row[data-card-href]{cursor:pointer}.notification-row[data-card-href]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} @@ -1543,13 +1543,13 @@ input[type=checkbox]{accent-color:var(--brand)}.check-row,.theme-toggle{display: input[type=text].password-visible{padding-right:.8rem}.password-control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.45rem;align-items:center}.password-control input{min-width:0}.password-toggle{background:var(--surface);color:var(--link-strong);border-color:var(--border);min-width:4.5rem}.auth-submit{margin-top:1.15rem}.auth-form{margin-top:.35rem}.auth-form .field-help{margin:.15rem 0 .4rem;color:var(--muted-strong)} .search-panel h1{margin-bottom:.75rem}.search-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center}.search-form input{min-width:0}.search-results{display:grid;gap:.65rem}.section-title{margin:.2rem 0 .65rem;font-size:1.05rem;color:var(--text)}.search-users{margin-bottom:0}.search-users .section-title{margin-top:0}.search-account{grid-template-columns:auto minmax(0,1fr)} input:focus,textarea:focus,select:focus,button:focus-visible,a:focus-visible{outline:3px solid var(--focus);outline-offset:2px}button,.primary{border:1px solid var(--brand);background:var(--brand);color:var(--brand-text);border-radius:7px;padding:.5rem .8rem;cursor:pointer;font-weight:700}button:hover,.primary:hover{background:var(--brand-hover);text-decoration:none} -nav button,.rail-nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.42rem .65rem}.rail-nav button:hover,.mobile-nav button:hover{background:var(--hover);color:var(--link-strong)} +nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.42rem .65rem}.rail-nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.5rem .65rem}.rail-nav button:hover,.mobile-nav button:hover{background:var(--hover);color:var(--link-strong)} .composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem}.file-control{display:inline-flex;align-items:center;gap:.6rem;margin:0;color:var(--link-strong);font-weight:700}.file-control input{max-width:15rem} .thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} .timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.post[data-card-href]{cursor:pointer}.post[data-card-href]:hover{border-color:var(--border-strong)}.post[data-card-href]:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px} .counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem} .profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} -@media (max-width:1100px){.app-shell{grid-template-columns:200px minmax(0,640px)}.right-rail{display:none}} +@media (max-width:1100px){.app-shell{--shell-side:220px;--shell-max:880px;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary))}.right-rail{display:none}} @media (max-width:820px){.app-shell{grid-template-columns:minmax(0,680px)}.left-rail,.right-rail{display:none}.mobile-nav{display:flex}} @media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} "#; From 3f3fa547fd94b1092bb33ea6b48fbca09ea88aaa Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 21 May 2026 21:20:34 -0700 Subject: [PATCH 04/23] Align left nav items in the shared shell --- src/render.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/render.rs b/src/render.rs index 7ad38de..34f0381 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1534,7 +1534,7 @@ nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:fl nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline} nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} .nav-badge{display:inline-grid;place-items:center;min-width:1.25rem;height:1.25rem;border-radius:999px;padding:0 .35rem;background:var(--brand);color:var(--brand-text);font-size:.78rem;font-weight:800;line-height:1} -main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:5rem;display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;gap:.2rem;width:100%;padding:.35rem}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} +main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:5rem;display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} .page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:.85rem;box-shadow:0 1px 2px var(--shadow)} .page-header h1,.section-heading h1,.panel h1{margin:0;font-size:1.45rem;line-height:1.2}.panel h1+table,.panel h1+form,.panel h1+p,.panel h1+dl{margin-top:.85rem}.page-header p,.muted,.empty-state p{color:var(--muted);margin:.35rem 0 0}.section-heading{display:flex;justify-content:space-between;gap:1rem;align-items:baseline;margin-bottom:.8rem} .notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.notification-row[data-card-href]{cursor:pointer}.notification-row[data-card-href]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} From e3202ca5e9087eff69ee2521ab5dd216ffb8ed99 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 21 May 2026 21:58:12 -0700 Subject: [PATCH 05/23] Add gzip response compression Compress eligible text responses when clients explicitly accept gzip, while leaving favicon and uploaded media responses uncompressed. Handle HEAD requests without response bodies and add coverage for gzip negotiation, skipped binary assets, and content-length behavior. --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 2 + src/compression.rs | 433 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/server.rs | 211 ++++++++++++++++++++-- 6 files changed, 639 insertions(+), 10 deletions(-) create mode 100644 src/compression.rs diff --git a/Cargo.lock b/Cargo.lock index 936f8e2..e61ae0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3144,6 +3144,7 @@ dependencies = [ "base64", "chrono", "clap", + "flate2", "futures-util", "html-escape", "http-body-util", diff --git a/Cargo.toml b/Cargo.toml index 83be5b5..a57308f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive", "env"] } futures-util = "0.3" +flate2 = "1.1" html-escape = "0.2" infer = "0.19" rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/README.md b/README.md index e96d0d4..82bac1e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ This repository is a **production-oriented MVP** — not a toy, but not overengi | Backups | Tar archive of DB + settings + media + optional Tor keys | | Restore | Path validation rejects traversal, symlinks, absolute paths, and Unicode bypass attempts | | Admin dashboard | Site health, users, media jobs, conversion state, and backup management | +| HTTP compression | Browser text responses use gzip when requested; media and binary uploads are intentionally left uncompressed | | Tor / Arti | Embedded onion-service startup — clearnet-only, Tor-only, or dual mode | --- @@ -438,6 +439,7 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op | `argon2` | Argon2id password hashing | | `rand_core` + `uuid` | Secure salts and opaque generated tokens/filenames | | `infer` | Content-based media type detection | +| `flate2` | Gzip response compression for browser text responses | | `tower-http` | Static upload serving and HTTP tracing | | `clap` | CLI argument parsing | | `toml` + `serde` | Config load/save | diff --git a/src/compression.rs b/src/compression.rs new file mode 100644 index 0000000..a7d31b5 --- /dev/null +++ b/src/compression.rs @@ -0,0 +1,433 @@ +use std::io::Write as _; + +use axum::body::{Body, to_bytes}; +use axum::extract::Request; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; +use axum::middleware::Next; +use axum::response::Response; +use flate2::Compression; +use flate2::write::GzEncoder; + +const MIN_COMPRESSIBLE_BYTES: usize = 1024; +const MAX_BUFFERED_RESPONSE_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResponseEncoding { + Gzip, +} + +impl ResponseEncoding { + const fn header_value(self) -> &'static str { + match self { + Self::Gzip => "gzip", + } + } +} + +pub async fn response_compression(mut request: Request, next: Next) -> Response { + let is_head = request.method() == Method::HEAD; + let path = request.uri().path().to_owned(); + let encoding = choose_response_encoding(request.headers()); + + if is_head { + *request.method_mut() = Method::GET; + } + + let response = next.run(request).await; + let Some(encoding) = encoding else { + if is_head && should_buffer_head_response(&path, &response) { + return head_response_with_get_length(response).await; + } + return head_safe_response(response, is_head); + }; + + if should_skip_path(&path) || !response_is_compressible(&response) { + if is_head && should_buffer_head_response(&path, &response) { + return head_response_with_get_length(response).await; + } + return head_safe_response(response, is_head); + } + + let (mut parts, body) = response.into_parts(); + let body = match to_bytes(body, MAX_BUFFERED_RESPONSE_BYTES).await { + Ok(body) => body, + Err(error) => { + tracing::warn!(error = %error, "failed to buffer response for compression"); + return Response::from_parts(parts, Body::empty()); + } + }; + + if !meets_minimum_size_threshold(body.len()) { + set_content_length(&mut parts.headers, body.len()); + return Response::from_parts(parts, body_for_method(body.to_vec(), is_head)); + } + + let compressed = match gzip(&body) { + Ok(compressed) => compressed, + Err(error) => { + tracing::warn!(error = %error, "failed to compress response"); + set_content_length(&mut parts.headers, body.len()); + return Response::from_parts(parts, body_for_method(body.to_vec(), is_head)); + } + }; + + if compressed.len() >= body.len() { + set_content_length(&mut parts.headers, body.len()); + return Response::from_parts(parts, body_for_method(body.to_vec(), is_head)); + } + + parts.headers.insert( + header::CONTENT_ENCODING, + HeaderValue::from_static(encoding.header_value()), + ); + merge_vary_accept_encoding(&mut parts.headers); + set_content_length(&mut parts.headers, compressed.len()); + Response::from_parts(parts, body_for_method(compressed, is_head)) +} + +fn response_is_compressible(response: &Response) -> bool { + status_allows_body(response.status()) + && !has_content_encoding(response.headers()) + && content_length_allows_buffering(response.headers()) + && response + .headers() + .get(header::CONTENT_TYPE) + .is_some_and(is_compressible_content_type) +} + +const fn meets_minimum_size_threshold(len: usize) -> bool { + len >= MIN_COMPRESSIBLE_BYTES +} + +fn head_safe_response(response: Response, is_head: bool) -> Response { + if !is_head { + return response; + } + let (parts, _body) = response.into_parts(); + Response::from_parts(parts, Body::empty()) +} + +fn should_buffer_head_response(path: &str, response: &Response) -> bool { + !path.starts_with("/uploads/") + && !response.headers().contains_key(header::CONTENT_LENGTH) + && status_allows_body(response.status()) + && content_length_allows_buffering(response.headers()) +} + +async fn head_response_with_get_length(response: Response) -> Response { + let (mut parts, body) = response.into_parts(); + match to_bytes(body, MAX_BUFFERED_RESPONSE_BYTES).await { + Ok(body) => { + set_content_length(&mut parts.headers, body.len()); + Response::from_parts(parts, Body::empty()) + } + Err(error) => { + tracing::warn!(error = %error, "failed to buffer HEAD response"); + Response::from_parts(parts, Body::empty()) + } + } +} + +fn body_for_method(body: Vec, is_head: bool) -> Body { + if is_head { + Body::empty() + } else { + Body::from(body) + } +} + +fn content_length_allows_buffering(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_none_or(|len| len <= MAX_BUFFERED_RESPONSE_BYTES) +} + +fn set_content_length(headers: &mut HeaderMap, len: usize) { + let value = len.to_string(); + match HeaderValue::from_str(&value) { + Ok(value) => { + headers.insert(header::CONTENT_LENGTH, value); + } + Err(error) => { + tracing::warn!(error = %error, "failed to set compressed content length"); + headers.remove(header::CONTENT_LENGTH); + } + } +} + +fn status_allows_body(status: StatusCode) -> bool { + !status.is_informational() + && status != StatusCode::NO_CONTENT + && status != StatusCode::NOT_MODIFIED +} + +fn should_skip_path(path: &str) -> bool { + path == "/favicon.ico" || path.starts_with("/uploads/") +} + +fn has_content_encoding(headers: &HeaderMap) -> bool { + headers.contains_key(header::CONTENT_ENCODING) +} + +fn choose_response_encoding(headers: &HeaderMap) -> Option { + let mut gzip = EncodingPreference::absent(); + for value in headers.get_all(header::ACCEPT_ENCODING) { + let Ok(value) = value.to_str() else { + continue; + }; + for accepted in parse_accept_encoding(value) { + if accepted.name.eq_ignore_ascii_case("gzip") { + gzip.merge(accepted.q); + } + } + } + + if gzip.is_accepted() { + Some(ResponseEncoding::Gzip) + } else { + None + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct EncodingPreference { + q: f32, + present: bool, +} + +impl EncodingPreference { + const fn absent() -> Self { + Self { + q: 0.0, + present: false, + } + } + + fn merge(&mut self, q: f32) { + self.present = true; + self.q = self.q.max(q); + } + + const fn is_accepted(self) -> bool { + self.present && self.q > 0.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct AcceptedEncoding<'a> { + name: &'a str, + q: f32, +} + +fn parse_accept_encoding(value: &str) -> impl Iterator> { + value.split(',').filter_map(|part| { + let mut pieces = part.split(';'); + let name = pieces.next()?.trim(); + if name.is_empty() { + return None; + } + let mut q = 1.0_f32; + for parameter in pieces { + let Some((key, value)) = parameter.trim().split_once('=') else { + continue; + }; + if key.trim().eq_ignore_ascii_case("q") { + q = value.trim().parse::().unwrap_or(0.0).clamp(0.0, 1.0); + } + } + Some(AcceptedEncoding { name, q }) + }) +} + +fn is_compressible_content_type(value: &HeaderValue) -> bool { + let Ok(value) = value.to_str() else { + return false; + }; + let media_type = value + .split_once(';') + .map_or(value, |(media_type, _parameters)| media_type) + .trim() + .to_ascii_lowercase(); + + media_type.starts_with("text/") + || matches!( + media_type.as_str(), + "application/javascript" + | "application/json" + | "application/manifest+json" + | "application/rss+xml" + | "application/xhtml+xml" + | "application/xml" + | "image/svg+xml" + ) + || media_type.ends_with("+json") + || media_type.ends_with("+xml") +} + +fn gzip(body: &[u8]) -> std::io::Result> { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(body)?; + encoder.finish() +} + +fn merge_vary_accept_encoding(headers: &mut HeaderMap) { + let mut values = headers + .get_all(header::VARY) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(); + + if values.iter().any(|value| { + vary_contains_token(value, "*") || vary_contains_token(value, "accept-encoding") + }) { + return; + } + + values.push("Accept-Encoding"); + let merged = values.join(", "); + match HeaderValue::from_str(&merged) { + Ok(value) => { + headers.insert(header::VARY, value); + } + Err(error) => { + tracing::warn!(error = %error, "failed to merge Vary header"); + } + } +} + +fn vary_contains_token(value: &str, token: &str) -> bool { + value + .split(',') + .any(|part| part.trim().eq_ignore_ascii_case(token)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accept_encoding_parser_reads_q_values() { + let encodings = parse_accept_encoding("br, gzip;q=0.7, deflate; q=0").collect::>(); + + assert_eq!( + encodings, + vec![ + AcceptedEncoding { name: "br", q: 1.0 }, + AcceptedEncoding { + name: "gzip", + q: 0.7 + }, + AcceptedEncoding { + name: "deflate", + q: 0.0 + }, + ] + ); + } + + #[test] + fn chooses_gzip_only_when_explicitly_accepted() { + let mut headers = HeaderMap::new(); + assert_eq!(choose_response_encoding(&headers), None); + + headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); + assert_eq!(choose_response_encoding(&headers), None); + + headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip;q=0"), + ); + assert_eq!(choose_response_encoding(&headers), None); + + headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("br, gzip;q=0.5"), + ); + assert_eq!( + choose_response_encoding(&headers), + Some(ResponseEncoding::Gzip) + ); + } + + #[test] + fn content_type_decision_accepts_text_candidates() { + for value in [ + "text/html; charset=utf-8", + "text/css", + "application/javascript; charset=utf-8", + "application/json", + "application/activity+json", + "application/xml", + "image/svg+xml", + ] { + assert!(is_compressible_content_type(&HeaderValue::from_static( + value + ))); + } + } + + #[test] + fn content_type_decision_rejects_binary_media() { + for value in [ + "image/png", + "image/webp", + "video/mp4", + "audio/mpeg", + "application/pdf", + "application/zip", + "application/octet-stream", + ] { + assert!(!is_compressible_content_type(&HeaderValue::from_static( + value + ))); + } + } + + #[test] + fn already_encoded_responses_are_not_candidates() { + let mut response = Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .body(Body::from("")) + .expect("response"); + assert!(response_is_compressible(&response)); + + response + .headers_mut() + .insert(header::CONTENT_ENCODING, HeaderValue::from_static("br")); + assert!(!response_is_compressible(&response)); + } + + #[test] + fn content_length_over_buffer_limit_is_not_candidate() { + let response = Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .header( + header::CONTENT_LENGTH, + (MAX_BUFFERED_RESPONSE_BYTES + 1).to_string(), + ) + .body(Body::empty()) + .expect("response"); + + assert!(!response_is_compressible(&response)); + } + + #[test] + fn size_threshold_skips_tiny_responses() { + assert!(!meets_minimum_size_threshold(MIN_COMPRESSIBLE_BYTES - 1)); + assert!(meets_minimum_size_threshold(MIN_COMPRESSIBLE_BYTES)); + } + + #[test] + fn vary_accept_encoding_is_merged_without_clobbering() { + let mut headers = HeaderMap::new(); + headers.insert(header::VARY, HeaderValue::from_static("Cookie")); + + merge_vary_accept_encoding(&mut headers); + assert_eq!(headers[header::VARY], "Cookie, Accept-Encoding"); + + merge_vary_accept_encoding(&mut headers); + assert_eq!(headers[header::VARY], "Cookie, Accept-Encoding"); + } +} diff --git a/src/lib.rs b/src/lib.rs index d19cef7..f033aad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod admin; pub mod auth; pub mod backup; pub mod cli; +pub mod compression; pub mod config; pub mod csrf; pub mod db; diff --git a/src/server.rs b/src/server.rs index f149f0f..7db1807 100644 --- a/src/server.rs +++ b/src/server.rs @@ -7,6 +7,7 @@ use axum::Router; use axum::extract::connect_info::ConnectInfo; use axum::extract::{DefaultBodyLimit, Form, Multipart, Path, Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header}; +use axum::middleware; use axum::response::{Html, IntoResponse as _, Redirect, Response}; use axum::routing::{get, post}; use rusqlite::{OptionalExtension as _, params}; @@ -129,6 +130,9 @@ pub fn router(state: Arc) -> Router { ServeDir::new(state.paths.uploads_thumbs.clone()), ) .layer(DefaultBodyLimit::max(upload_body_limit)) + .layer(middleware::from_fn( + crate::compression::response_compression, + )) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -3104,6 +3108,8 @@ fn user_actor(user_id: i64) -> String { #[cfg(test)] mod tests { use super::*; + use flate2::read::GzDecoder; + use std::io::Read as _; use std::path::PathBuf; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; @@ -3117,6 +3123,7 @@ mod tests { struct TestResponse { status: u16, headers: Vec<(String, String)>, + body_bytes: Vec, body: String, } @@ -4310,6 +4317,159 @@ mod tests { ); } + #[tokio::test] + async fn html_response_with_gzip_accept_encoding_is_compressed() { + let server = spawn_test_server().await; + + let response = request( + &server.base_url, + "GET", + "/home", + &[("accept-encoding", "gzip")], + Vec::new(), + ) + .await; + + assert_eq!(response.status, 200); + assert_header(&response, "content-encoding", "gzip"); + assert_vary_contains_accept_encoding(&response); + assert_eq!( + content_length(&response), + Some(response.body_bytes.len()), + "compressed content-length should match wire body length" + ); + let body = gzip_decode(&response.body_bytes); + assert!(body.contains("Home Feed - RustPost")); + } + + #[tokio::test] + async fn html_response_without_accept_encoding_is_not_compressed() { + let server = spawn_test_server().await; + + let response = request(&server.base_url, "GET", "/home", &[], Vec::new()).await; + + assert_eq!(response.status, 200); + assert_no_header(&response, "content-encoding"); + assert!( + response + .body + .contains("Home Feed - RustPost") + ); + } + + #[tokio::test] + async fn uploaded_media_response_is_not_compressed() { + let server = spawn_test_server().await; + let cookie = register_test_user(&server, "alice").await; + let home = get_with_cookie(&server, "/home", &cookie).await; + let csrf = csrf_token(&home.body); + let posted = request( + &server.base_url, + "POST", + "/posts", + &[ + ("cookie", &cookie), + ( + "content-type", + "multipart/form-data; boundary=post-boundary", + ), + ], + multipart_body_with_file( + "post-boundary", + &[("csrf", csrf.as_str()), ("text", "image post")], + "media", + "photo.png", + "image/png", + &tiny_png_bytes(), + ), + ) + .await; + assert_eq!(posted.status, 303); + + let conn = rusqlite::Connection::open(server.data_dir.join("db/rustpost.sqlite3")) + .expect("open database"); + let public_path: String = conn + .query_row("SELECT public_path FROM media LIMIT 1", [], |row| { + row.get(0) + }) + .expect("media path"); + drop(conn); + + let media = request( + &server.base_url, + "GET", + &public_path, + &[("accept-encoding", "gzip")], + Vec::new(), + ) + .await; + + assert_eq!(media.status, 200); + assert_no_header(&media, "content-encoding"); + assert!(media.body_bytes.starts_with(&tiny_png_bytes()[..8])); + } + + #[tokio::test] + async fn head_response_uses_compression_headers_without_body() { + let server = spawn_test_server().await; + + let response = request( + &server.base_url, + "HEAD", + "/home", + &[("accept-encoding", "gzip")], + Vec::new(), + ) + .await; + + assert_eq!(response.status, 200); + assert_header(&response, "content-encoding", "gzip"); + assert_vary_contains_accept_encoding(&response); + assert_eq!(response.body_bytes.len(), 0); + assert!( + content_length(&response).is_some_and(|len| len > 0), + "compressed HEAD response should keep the GET content length" + ); + } + + #[tokio::test] + async fn head_response_without_accept_encoding_keeps_get_length_without_body() { + let server = spawn_test_server().await; + let get = request(&server.base_url, "GET", "/home", &[], Vec::new()).await; + let head = request(&server.base_url, "HEAD", "/home", &[], Vec::new()).await; + + assert_eq!(head.status, 200); + assert_no_header(&head, "content-encoding"); + assert_eq!(head.body_bytes.len(), 0); + assert_eq!(content_length(&head), Some(get.body_bytes.len())); + } + + #[tokio::test] + async fn head_response_for_skipped_favicon_keeps_length_without_compression() { + let server = spawn_test_server().await; + let get = request( + &server.base_url, + "GET", + "/favicon.ico", + &[("accept-encoding", "gzip")], + Vec::new(), + ) + .await; + let head = request( + &server.base_url, + "HEAD", + "/favicon.ico", + &[("accept-encoding", "gzip")], + Vec::new(), + ) + .await; + + assert_eq!(head.status, 200); + assert_no_header(&head, "content-encoding"); + assert_eq!(head.body_bytes.len(), 0); + assert_eq!(content_length(&head), Some(get.body_bytes.len())); + } + #[tokio::test] async fn admin_can_upload_replace_and_remove_png_favicon() { let server = spawn_test_server_with_admin().await; @@ -5298,8 +5458,13 @@ mod tests { } fn parse_response(bytes: &[u8]) -> TestResponse { - let raw = String::from_utf8_lossy(bytes); - let (head, body) = raw.split_once("\r\n\r\n").expect("response split"); + let split = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("response split"); + let head = String::from_utf8_lossy(&bytes[..split]); + let body_bytes = bytes[split + 4..].to_vec(); + let body = String::from_utf8_lossy(&body_bytes).into_owned(); let mut lines = head.lines(); let status = lines .next() @@ -5315,7 +5480,8 @@ mod tests { TestResponse { status, headers, - body: body.to_owned(), + body_bytes, + body, } } @@ -5338,16 +5504,41 @@ mod tests { } fn assert_header(response: &TestResponse, name: &str, expected: &str) { - assert_eq!( - response - .headers - .iter() - .find(|(header_name, _)| header_name == name) - .map(|(_, value)| value.as_str()), - Some(expected) + assert_eq!(header_value(response, name), Some(expected)); + } + + fn assert_no_header(response: &TestResponse, name: &str) { + assert_eq!(header_value(response, name), None); + } + + fn assert_vary_contains_accept_encoding(response: &TestResponse) { + let vary = header_value(response, "vary").expect("vary header"); + assert!( + vary.split(',') + .any(|part| part.trim().eq_ignore_ascii_case("accept-encoding")), + "Vary header should include Accept-Encoding: {vary}" ); } + fn header_value<'a>(response: &'a TestResponse, name: &str) -> Option<&'a str> { + response + .headers + .iter() + .find(|(header_name, _)| header_name == name) + .map(|(_, value)| value.as_str()) + } + + fn content_length(response: &TestResponse) -> Option { + header_value(response, "content-length")?.parse().ok() + } + + fn gzip_decode(bytes: &[u8]) -> String { + let mut decoder = GzDecoder::new(bytes); + let mut output = String::new(); + decoder.read_to_string(&mut output).expect("gzip body"); + output + } + fn deep_settings_form_body(csrf: &str, intent: &str, overrides: &[(&str, &str)]) -> Vec { let values = crate::admin::DeepSettingsValues::from_settings(&Settings::default()); let mut pairs = vec![ From 0deadc2b3ced59ac3c1ebd17af5c8a98525648d1 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 21 May 2026 22:14:33 -0700 Subject: [PATCH 06/23] Polish RustPost composer UI Add contextual placeholder text to post, reply, and quote composer textareas while preserving existing form fields and limits. Align sticky desktop rails to shared header and shell spacing tokens so side panels and the Home card start at the same vertical position. --- src/render.rs | 53 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/render.rs b/src/render.rs index 34f0381..7ec520a 100644 --- a/src/render.rs +++ b/src/render.rs @@ -579,11 +579,16 @@ pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) }); let csrf = csrf.unwrap_or_default(); let input_id = parent.map_or_else(|| "post-text".to_owned(), |id| format!("reply-text-{id}")); + let placeholder = if parent.is_some() { + "Write a reply..." + } else { + "What's happening?" + }; format!( r#"

{}

{} remaining
{} - +
"#, if parent.is_some() { @@ -598,7 +603,8 @@ pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) html_escape::encode_double_quoted_attribute(&input_id), html_escape::encode_double_quoted_attribute(&input_id), max_text_chars, - max_text_chars + max_text_chars, + html_escape::encode_double_quoted_attribute(placeholder) ) } @@ -608,7 +614,7 @@ pub fn quote_composer(csrf: &str, quote: &QuotePreview, max_text_chars: usize) - r#"

Quote post

{max_text_chars} remaining
{preview}
- +
"#, quote.id, @@ -1524,17 +1530,17 @@ pub fn error_page(status: StatusCode, message: &str) -> String { } const CSS: &str = r#" -:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;line-height:1.5;--bg:#f5f6f1;--surface:#fff;--surface-subtle:#fbfcfa;--surface-muted:#f4f5f2;--header-bg:rgba(255,255,255,.96);--text:#202124;--text-strong:#172017;--muted:#667064;--muted-strong:#59625a;--border:#dfe4dc;--border-strong:#b9c2b8;--link:#1f5f8b;--link-strong:#24445f;--brand:#163b2f;--brand-hover:#235544;--brand-text:#fff;--hover:#eef3f0;--focus:#93c5fd;--shadow:rgba(20,35,30,.04);--reply-border:#c8d8d0;--avatar-bg:#eef3f0;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4;--shell-side:240px;--shell-primary:640px;--shell-gap:1.25rem;--shell-max:1160px} +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;line-height:1.5;--bg:#f5f6f1;--surface:#fff;--surface-subtle:#fbfcfa;--surface-muted:#f4f5f2;--header-bg:rgba(255,255,255,.96);--text:#202124;--text-strong:#172017;--muted:#667064;--muted-strong:#59625a;--border:#dfe4dc;--border-strong:#b9c2b8;--link:#1f5f8b;--link-strong:#24445f;--brand:#163b2f;--brand-hover:#235544;--brand-text:#fff;--hover:#eef3f0;--focus:#93c5fd;--shadow:rgba(20,35,30,.04);--reply-border:#c8d8d0;--avatar-bg:#eef3f0;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4;--shell-side:240px;--shell-primary:640px;--shell-gap:1.25rem;--shell-max:1160px;--header-padding-y:.8rem;--header-brand-size:2rem;--hairline:1px;--rail-sticky-top:calc(var(--header-brand-size) + var(--header-padding-y) + var(--header-padding-y) + var(--shell-gap) + var(--hairline))} :root[data-theme="dark"]{color-scheme:dark;--bg:#111827;--surface:#182231;--surface-subtle:#1d2939;--surface-muted:#233044;--header-bg:rgba(17,24,39,.96);--text:#eef4fb;--text-strong:#f8fafc;--muted:#c3cfdd;--muted-strong:#d4deea;--border:#344256;--border-strong:#596b83;--link:#8fc7ff;--link-strong:#badcff;--brand:#4f8fc7;--brand-hover:#6aa8df;--brand-text:#06111f;--hover:#243349;--focus:#fbbf24;--shadow:rgba(0,0,0,.26);--reply-border:#4f6680;--avatar-bg:#243349;--danger:#ffb4a2;--danger-strong:#ffd2c7;--danger-bg:#3a2020;--danger-border:#8f4d43;--success-bg:#163321;--success-border:#4c8a61;--media-bg:#0f172a} *{box-sizing:border-box}body{margin:0;min-width:320px;color:var(--text);background:var(--bg)}a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline} .site-header{position:sticky;top:0;z-index:10;background:var(--header-bg);border-bottom:1px solid var(--border);backdrop-filter:blur(8px)} -.header-inner{max-width:var(--shell-max);margin:0 auto;padding:.8rem 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} -.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong)}.brand-mark{display:inline-grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--brand);color:var(--brand-text)} +.header-inner{max-width:var(--shell-max);margin:0 auto;padding:var(--header-padding-y) 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} +.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong)}.brand-mark{display:inline-grid;place-items:center;width:var(--header-brand-size);height:var(--header-brand-size);border-radius:7px;background:var(--brand);color:var(--brand-text)} nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}nav a,nav button,.button-link{display:inline-flex;align-items:center;gap:.35rem;min-height:2.15rem;border-radius:7px;padding:.42rem .65rem;color:var(--link-strong);border:1px solid transparent;background:transparent} nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline} nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} .nav-badge{display:inline-grid;place-items:center;min-width:1.25rem;height:1.25rem;border-radius:999px;padding:0 .35rem;background:var(--brand);color:var(--brand-text);font-size:.78rem;font-weight:800;line-height:1} -main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:5rem;display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} +main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:var(--rail-sticky-top);display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} .page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:.85rem;box-shadow:0 1px 2px var(--shadow)} .page-header h1,.section-heading h1,.panel h1{margin:0;font-size:1.45rem;line-height:1.2}.panel h1+table,.panel h1+form,.panel h1+p,.panel h1+dl{margin-top:.85rem}.page-header p,.muted,.empty-state p{color:var(--muted);margin:.35rem 0 0}.section-heading{display:flex;justify-content:space-between;gap:1rem;align-items:baseline;margin-bottom:.8rem} .notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.notification-row[data-card-href]{cursor:pointer}.notification-row[data-card-href]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} @@ -1728,13 +1734,36 @@ mod tests { } #[test] - fn composer_has_live_remaining_counter_without_placeholder() { - let body = composer(Some("csrf"), Some(10), 512); - assert!(body.contains("512 remaining")); + fn composer_has_live_remaining_counter_and_contextual_placeholder() { + let post = composer(Some("csrf"), None, 512); + assert!(post.contains("512 remaining")); + assert!(post.contains("data-character-limit=\"512\"")); + assert!(post.contains("maxlength=\"512\"")); + assert!(post.contains("What is happening?")); + assert!(post.contains(r#"placeholder="What's happening?""#)); + + let reply = composer(Some("csrf"), Some(10), 512); + assert!(reply.contains(r#"id="reply-text-10""#)); + assert!(reply.contains(r#"placeholder="Write a reply...""#)); + } + + #[test] + fn quote_composer_has_contextual_placeholder() { + let quote = QuotePreview { + id: 42, + username: Some("ada".to_owned()), + display_name: Some("Ada".to_owned()), + anonymous_label: None, + text: "quoted post".to_owned(), + created_at: "2026-05-18 10:30".to_owned(), + unavailable: false, + }; + let body = quote_composer("csrf", "e, 512); + + assert!(body.contains(r#"id="quote-text""#)); + assert!(body.contains(r#"placeholder="Add your thoughts...""#)); assert!(body.contains("data-character-limit=\"512\"")); assert!(body.contains("maxlength=\"512\"")); - assert!(body.contains("What is happening?")); - assert!(!body.contains("placeholder=")); } #[test] From e4b9af2e118d71cf40ac5d6f198c751a389952d6 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 21 May 2026 22:31:45 -0700 Subject: [PATCH 07/23] Add noscript banner and JS progressive enhancement Show a noscript status banner and mark the document when JS runs (document.documentElement.classList.add("js-enabled")). Adjust post/notification card markup and styles for progressive enhancement: keep data-card-href on cards, add an explicit permalink anchor for no-JS navigation, remove tabindex on non-JS notification rows, and scope hover/cursor/focus behaviors to .js-enabled. Hide the password-toggle by default and reveal it only when JS is enabled. Update related CSS and client script and adjust tests to reflect the new no-JS fallbacks and JS-enabled behaviors. --- src/render.rs | 47 ++++++++++++++++++++++++++++++++++++----------- src/server.rs | 2 +- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/render.rs b/src/render.rs index 7ec520a..9c64cfd 100644 --- a/src/render.rs +++ b/src/render.rs @@ -140,6 +140,7 @@ pub fn layout_with_context( +
JavaScript is disabled. RustPost will use standard links and forms.
{}
{}
{}
{} alpha{}
@@ -266,7 +267,9 @@ fn password_requirement_hint(min_password_length: usize) -> String { } } -const CLIENT_SCRIPT: &str = r#"function cardInteractiveTarget(target) { +const CLIENT_SCRIPT: &str = r#"document.documentElement.classList.add("js-enabled"); + +function cardInteractiveTarget(target) { if (!(target instanceof Element)) { return null; } @@ -1030,9 +1033,14 @@ fn post_card_with_options( String::new() }; let card_attrs = if options.clickable_card { + format!(r#" data-card-href="/posts/{}""#, post.id) + } else { + String::new() + }; + let permalink = if options.clickable_card { format!( - r#" data-card-href="/posts/{}" tabindex="0" aria-label="Open post {}""#, - post.id, post.id + r#"Open post"#, + post.id ) } else { String::new() @@ -1050,7 +1058,7 @@ fn post_card_with_options( .as_ref() .map_or_else(String::new, quote_preview_card); format!( - r#"
{}{}
{}
{}
{}
{}
{}{}
{} likes{} reposts{} replies
{}
"#, + r#"
{}{}
{}
{}
{}
{}
{}{}
{} likes{} reposts{} replies{}
{}
"#, post_class, post.id, post.id, @@ -1067,6 +1075,7 @@ fn post_card_with_options( post.like_count, post.repost_count, post.reply_count, + permalink, controls ) } @@ -1346,7 +1355,7 @@ fn notification_row(notification: &NotificationView) -> String { let target = notification_target(notification); let target_attrs = target.as_ref().map_or_else(String::new, |href| { format!( - r#" data-card-href="{}" tabindex="0""#, + r#" data-card-href="{}""#, html_escape::encode_double_quoted_attribute(href) ) }); @@ -1543,17 +1552,17 @@ nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:var(--rail-sticky-top);display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} .page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:.85rem;box-shadow:0 1px 2px var(--shadow)} .page-header h1,.section-heading h1,.panel h1{margin:0;font-size:1.45rem;line-height:1.2}.panel h1+table,.panel h1+form,.panel h1+p,.panel h1+dl{margin-top:.85rem}.page-header p,.muted,.empty-state p{color:var(--muted);margin:.35rem 0 0}.section-heading{display:flex;justify-content:space-between;gap:1rem;align-items:baseline;margin-bottom:.8rem} -.notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.notification-row[data-card-href]{cursor:pointer}.notification-row[data-card-href]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} +.notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.js-enabled .notification-row[data-card-href]{cursor:pointer}.js-enabled .notification-row[data-card-href]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} label{display:block;font-weight:700;margin:.85rem 0 .35rem}input,textarea,button,select{font:inherit}input[type=text],input[type=search],input[type=password],input[type=url],input:not([type]),textarea,select{width:100%;padding:.72rem .8rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text)}textarea{resize:vertical;min-height:7rem}::placeholder{color:var(--muted)} input[type=checkbox]{accent-color:var(--brand)}.check-row,.theme-toggle{display:flex;align-items:center;gap:.55rem;font-weight:700;color:var(--text)}.theme-toggle{padding:.65rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle)}.theme-toggle input{width:auto} -input[type=text].password-visible{padding-right:.8rem}.password-control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.45rem;align-items:center}.password-control input{min-width:0}.password-toggle{background:var(--surface);color:var(--link-strong);border-color:var(--border);min-width:4.5rem}.auth-submit{margin-top:1.15rem}.auth-form{margin-top:.35rem}.auth-form .field-help{margin:.15rem 0 .4rem;color:var(--muted-strong)} +input[type=text].password-visible{padding-right:.8rem}.password-control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.45rem;align-items:center}.password-control input{min-width:0}.password-toggle{display:none;background:var(--surface);color:var(--link-strong);border-color:var(--border);min-width:4.5rem}.js-enabled .password-toggle{display:inline-block}.auth-submit{margin-top:1.15rem}.auth-form{margin-top:.35rem}.auth-form .field-help{margin:.15rem 0 .4rem;color:var(--muted-strong)} .search-panel h1{margin-bottom:.75rem}.search-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center}.search-form input{min-width:0}.search-results{display:grid;gap:.65rem}.section-title{margin:.2rem 0 .65rem;font-size:1.05rem;color:var(--text)}.search-users{margin-bottom:0}.search-users .section-title{margin-top:0}.search-account{grid-template-columns:auto minmax(0,1fr)} input:focus,textarea:focus,select:focus,button:focus-visible,a:focus-visible{outline:3px solid var(--focus);outline-offset:2px}button,.primary{border:1px solid var(--brand);background:var(--brand);color:var(--brand-text);border-radius:7px;padding:.5rem .8rem;cursor:pointer;font-weight:700}button:hover,.primary:hover{background:var(--brand-hover);text-decoration:none} nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.42rem .65rem}.rail-nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.5rem .65rem}.rail-nav button:hover,.mobile-nav button:hover{background:var(--hover);color:var(--link-strong)} .composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem}.file-control{display:inline-flex;align-items:center;gap:.6rem;margin:0;color:var(--link-strong);font-weight:700}.file-control input{max-width:15rem} .thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} -.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.post[data-card-href]{cursor:pointer}.post[data-card-href]:hover{border-color:var(--border-strong)}.post[data-card-href]:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px} -.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem} +.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px} +.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.post-permalink{font-weight:700;color:var(--link-strong)}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem}.noscript-banner{max-width:1100px;margin:.7rem auto 0;padding:.65rem .85rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);color:var(--muted-strong)}.js-enabled .noscript-banner{display:none} .profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} @media (max-width:1100px){.app-shell{--shell-side:220px;--shell-max:880px;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary))}.right-rail{display:none}} @media (max-width:820px){.app-shell{grid-template-columns:minmax(0,680px)}.left-rail,.right-rail{display:none}.mobile-nav{display:flex}} @@ -1597,6 +1606,20 @@ mod tests { assert!(body.contains(r#""#)); } + #[test] + fn layout_includes_no_javascript_status_and_styles() { + let body = layout(None, "Home Feed", "

body

", "My Microblog"); + + assert!(body.contains(r#"class="noscript-banner" role="status""#)); + assert!(body.contains(".js-enabled .noscript-banner")); + assert!(body.contains("display:none")); + assert!( + client_script().contains(r#"document.documentElement.classList.add("js-enabled")"#) + ); + assert!(body.contains("JavaScript is disabled.")); + assert!(body.contains("RustPost will use standard links and forms.")); + } + #[test] fn register_form_uses_configured_password_length() { let body = register_form(None, 5); @@ -1772,8 +1795,8 @@ mod tests { let body = post_card(&post, None, None); assert!(body.contains(r#"data-card-href="/posts/42""#)); - assert!(body.contains(r#"tabindex="0""#)); - assert!(!body.contains(r#">Open post"#)); + assert!(!body.contains(r#"tabindex="0""#)); + assert!(body.contains(r#"Open post"#)); assert!(!body.contains("Open thread")); assert!(!body.contains("2026-05-18 10:30")); assert!(!body.contains(r#"class="post-time""#)); @@ -1805,9 +1828,11 @@ mod tests { .expect("root card"); assert!(!root_card.contains(r#"data-card-href="/posts/42""#)); assert!(!root_card.contains(r#"tabindex="0""#)); + assert!(!root_card.contains(r#"href="/posts/42">Open post"#)); assert!(root_card.contains(r#"2026-05-18 10:30"#)); assert!(!root_card.contains(r#"class="post-time" href="/posts/42""#)); assert!(body.contains(r#"data-card-href="/posts/43""#)); + assert!(body.contains(r#"href="/posts/43">Open post"#)); } #[test] diff --git a/src/server.rs b/src/server.rs index 7db1807..a14398c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3396,7 +3396,7 @@ mod tests { assert!(home.body.contains("hello from the browser-shaped form")); assert!(home.body.contains(r#"class="post""#)); assert!(home.body.contains(r#"data-card-href="/posts/1""#)); - assert!(!home.body.contains(r#">Open post"#)); + assert!(home.body.contains(r#"href="/posts/1">Open post"#)); assert!(!home.body.contains("Open thread")); assert!(!home.body.contains(r#"class="post-time""#)); From 634e70dfde42d102e43c3573c02860bd050e19da Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 22 May 2026 16:12:56 -0700 Subject: [PATCH 08/23] Expand admin users investigation panel Add aggregated admin-only user investigation details, name and post-content search semantics, escaped match previews, empty states, and focused Rust coverage for admin user search behavior. --- src/admin.rs | 427 +++++++++++++++++++++++++++++++++++++++++++++++++- src/render.rs | 4 +- src/server.rs | 421 ++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 819 insertions(+), 33 deletions(-) diff --git a/src/admin.rs b/src/admin.rs index e8e0c82..ff352d2 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -3,7 +3,7 @@ use std::io::Write as _; use std::path::Path; use anyhow::Context as _; -use rusqlite::params; +use rusqlite::{Row, params, params_from_iter}; use serde::Deserialize; use crate::auth; @@ -719,6 +719,156 @@ pub struct MediaJobFailure { pub error_summary: String, } +const ADMIN_USERS_LIMIT: i64 = 100; +const ADMIN_POST_SEARCH_TERM_LIMIT: usize = 8; +const ADMIN_USERS_SQL: &str = r" +WITH name_matches AS ( + __NAME_MATCH_SQL__ +), +matching_posts AS ( + SELECT p.id, p.user_id, p.text, p.created_at + FROM posts p + WHERE p.user_id IS NOT NULL + AND p.is_deleted = 0 + AND (__POST_MATCH_SQL__) +), +post_match_counts AS ( + SELECT user_id, COUNT(*) AS matching_post_count + FROM matching_posts + GROUP BY user_id +), +ranked_post_matches AS ( + SELECT user_id, text, + ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY created_at DESC, id DESC + ) AS rank + FROM matching_posts +), +post_match_previews AS ( + SELECT user_id, text + FROM ranked_post_matches + WHERE rank = 1 +), +post_stats AS ( + SELECT user_id, COUNT(*) AS total_posts, MAX(created_at) AS last_post_at + FROM posts + WHERE user_id IS NOT NULL AND is_deleted = 0 + GROUP BY user_id +), +media_stats AS ( + SELECT owner_user_id AS user_id, COUNT(*) AS uploaded_media_count + FROM media + WHERE owner_user_id IS NOT NULL + GROUP BY owner_user_id +), +report_stats AS ( + SELECT p.user_id, COUNT(r.id) AS reports_on_posts_count + FROM reports r + JOIN posts p ON p.id = r.post_id + WHERE p.user_id IS NOT NULL + GROUP BY p.user_id +), +session_stats AS ( + SELECT user_id, MAX(created_at) AS last_session_at + FROM sessions + GROUP BY user_id +), +audit_stats AS ( + SELECT target, COUNT(*) AS moderation_action_count + FROM admin_audit_log + WHERE target LIKE 'user:%' + GROUP BY target +) +SELECT + u.id, + u.username, + u.display_name, + u.is_admin, + u.is_suspended, + u.is_deleted, + u.created_at, + u.updated_at, + session_stats.last_session_at, + post_stats.last_post_at, + COALESCE(post_stats.total_posts, 0), + COALESCE(media_stats.uploaded_media_count, 0), + COALESCE(report_stats.reports_on_posts_count, 0), + COALESCE(audit_stats.moderation_action_count, 0), + COALESCE(post_match_counts.matching_post_count, 0), + post_match_previews.text, + name_matches.id IS NOT NULL +FROM users u +LEFT JOIN name_matches ON name_matches.id = u.id +LEFT JOIN post_match_counts ON post_match_counts.user_id = u.id +LEFT JOIN post_match_previews ON post_match_previews.user_id = u.id +LEFT JOIN post_stats ON post_stats.user_id = u.id +LEFT JOIN media_stats ON media_stats.user_id = u.id +LEFT JOIN report_stats ON report_stats.user_id = u.id +LEFT JOIN session_stats ON session_stats.user_id = u.id +LEFT JOIN audit_stats ON audit_stats.target = ('user:' || u.id) +__FILTER_SQL__ +ORDER BY + CASE WHEN name_matches.id IS NOT NULL THEN 0 ELSE 1 END, + COALESCE(post_match_counts.matching_post_count, 0) DESC, + u.id DESC +LIMIT __LIMIT__ +"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminUserInvestigation { + pub id: i64, + pub username: String, + pub display_name: String, + pub is_admin: bool, + pub is_suspended: bool, + pub is_deleted: bool, + pub created_at: String, + pub updated_at: String, + pub last_session_at: Option, + pub last_post_at: Option, + pub total_posts: i64, + pub uploaded_media_count: i64, + pub reports_on_posts_count: i64, + pub moderation_action_count: i64, + pub matching_post_count: i64, + pub post_match_preview: Option, + pub matched_name: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminUserSearch { + pub user_query: Option, + pub post_search: ParsedAdminPostSearch, +} + +impl AdminUserSearch { + #[must_use] + pub fn new(user_query: &str, post_query: &str) -> Self { + Self { + user_query: normalize_admin_user_search(user_query), + post_search: parse_admin_post_search(post_query), + } + } + + #[must_use] + pub fn has_filter(&self) -> bool { + self.user_query.is_some() || self.post_search.mode.is_some() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedAdminPostSearch { + pub mode: Option, + pub malformed_quotes: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdminPostSearchMode { + Keywords(Vec), + ExactPhrase(String), +} + pub async fn create_admin( pool: &SqlitePool, settings: &Settings, @@ -844,17 +994,151 @@ pub async fn audit( .await } -pub async fn users(pool: &SqlitePool) -> anyhow::Result> { - pool.call(|conn| { - let mut stmt = conn.prepare("SELECT id, username, is_admin, is_suspended FROM users WHERE is_deleted = 0 ORDER BY id DESC LIMIT 100")?; +#[must_use] +pub fn normalize_admin_user_search(query: &str) -> Option { + let trimmed = query.trim().trim_start_matches('@').trim(); + (!trimmed.is_empty()).then(|| trimmed.to_ascii_lowercase()) +} + +#[must_use] +pub fn parse_admin_post_search(query: &str) -> ParsedAdminPostSearch { + let trimmed = query.trim(); + if trimmed.is_empty() { + return ParsedAdminPostSearch { + mode: None, + malformed_quotes: false, + }; + } + + if let Some(quoted) = trimmed + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + let phrase = quoted.trim(); + return ParsedAdminPostSearch { + mode: (!phrase.is_empty()).then(|| AdminPostSearchMode::ExactPhrase(phrase.to_owned())), + malformed_quotes: false, + }; + } + + let malformed_quotes = trimmed.contains('"'); + let cleaned = trimmed.replace('"', " "); + let terms = cleaned + .split_whitespace() + .take(ADMIN_POST_SEARCH_TERM_LIMIT) + .map(str::to_ascii_lowercase) + .collect::>(); + + ParsedAdminPostSearch { + mode: (!terms.is_empty()).then_some(AdminPostSearchMode::Keywords(terms)), + malformed_quotes, + } +} + +pub async fn users( + pool: &SqlitePool, + search: AdminUserSearch, +) -> anyhow::Result> { + pool.call(move |conn| { + let (sql, sql_params) = admin_users_sql(&search); + let mut stmt = conn.prepare(&sql)?; let rows = stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)? != 0, row.get::<_, i64>(3)? != 0)))? + .query_map(params_from_iter(sql_params.iter()), admin_user_from_row)? .collect::, _>>()?; Ok(rows) }) .await } +fn admin_users_sql(search: &AdminUserSearch) -> (String, Vec) { + let mut sql_params = Vec::new(); + let name_match_sql = name_match_sql(search.user_query.as_deref(), &mut sql_params); + let post_match_sql = search.post_search.mode.as_ref().map_or_else( + || "0".to_owned(), + |mode| post_match_condition(mode, &mut sql_params), + ); + let filter_sql = if search.has_filter() { + "WHERE name_matches.id IS NOT NULL OR post_match_counts.matching_post_count IS NOT NULL" + } else { + "" + }; + let sql = ADMIN_USERS_SQL + .replace("__NAME_MATCH_SQL__", name_match_sql) + .replace("__POST_MATCH_SQL__", &post_match_sql) + .replace("__FILTER_SQL__", filter_sql) + .replace("__LIMIT__", &ADMIN_USERS_LIMIT.to_string()); + (sql, sql_params) +} + +fn name_match_sql(user_query: Option<&str>, sql_params: &mut Vec) -> &'static str { + if let Some(user_query) = user_query { + let like = format!("%{}%", escape_like(user_query)); + sql_params.extend([like.clone(), like.clone(), like]); + r" + SELECT id + FROM users + WHERE normalized_username LIKE ? ESCAPE '\' + OR lower(username) LIKE ? ESCAPE '\' + OR lower(display_name) LIKE ? ESCAPE '\' + " + } else { + "SELECT id FROM users WHERE 0" + } +} + +fn admin_user_from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(AdminUserInvestigation { + id: row.get(0)?, + username: row.get(1)?, + display_name: row.get(2)?, + is_admin: row.get::<_, i64>(3)? != 0, + is_suspended: row.get::<_, i64>(4)? != 0, + is_deleted: row.get::<_, i64>(5)? != 0, + created_at: row.get(6)?, + updated_at: row.get(7)?, + last_session_at: row.get(8)?, + last_post_at: row.get(9)?, + total_posts: row.get(10)?, + uploaded_media_count: row.get(11)?, + reports_on_posts_count: row.get(12)?, + moderation_action_count: row.get(13)?, + matching_post_count: row.get(14)?, + post_match_preview: row.get(15)?, + matched_name: row.get::<_, i64>(16)? != 0, + }) +} + +fn post_match_condition(mode: &AdminPostSearchMode, sql_params: &mut Vec) -> String { + match mode { + AdminPostSearchMode::ExactPhrase(phrase) => { + sql_params.push(format!("%{}%", escape_like(&phrase.to_ascii_lowercase()))); + "lower(p.text) LIKE ? ESCAPE '\\'".to_owned() + } + AdminPostSearchMode::Keywords(terms) => { + sql_params.extend(terms.iter().map(|term| format!("%{}%", escape_like(term)))); + terms + .iter() + .map(|_| "lower(p.text) LIKE ? ESCAPE '\\'") + .collect::>() + .join(" AND ") + } + } +} + +fn escape_like(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '%' | '_' | '\\' => { + escaped.push('\\'); + escaped.push(character); + } + _ => escaped.push(character), + } + } + escaped +} + pub async fn recent_media_jobs(pool: &SqlitePool) -> anyhow::Result> { pool.call(|conn| { let mut stmt = conn.prepare( @@ -946,6 +1230,31 @@ mod tests { use super::*; + async fn test_pool() -> (tempfile::TempDir, SqlitePool, Settings) { + let temp = tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + (temp, pool, Settings::default()) + } + + async fn register(pool: &SqlitePool, settings: &Settings, username: &str) -> i64 { + auth::register_user(pool, settings, username, "very secure password", false) + .await + .expect("register user") + } + + async fn create_post(pool: &SqlitePool, settings: &Settings, user_id: i64, text: &str) -> i64 { + crate::social::create_post(pool, settings, Some(user_id), text, None, &[]) + .await + .expect("create post") + } + + fn usernames(rows: &[AdminUserInvestigation]) -> Vec<&str> { + rows.iter().map(|row| row.username.as_str()).collect() + } + fn form_from_settings(settings: &Settings) -> DeepSettingsForm { let values = DeepSettingsValues::from_settings(settings); DeepSettingsForm { @@ -975,6 +1284,114 @@ mod tests { } } + #[test] + fn admin_post_search_parses_keywords_quotes_and_malformed_quotes() { + assert_eq!( + parse_admin_post_search("rust admin"), + ParsedAdminPostSearch { + mode: Some(AdminPostSearchMode::Keywords(vec![ + "rust".to_owned(), + "admin".to_owned() + ])), + malformed_quotes: false, + } + ); + assert_eq!( + parse_admin_post_search(r#""hello world""#), + ParsedAdminPostSearch { + mode: Some(AdminPostSearchMode::ExactPhrase("hello world".to_owned())), + malformed_quotes: false, + } + ); + assert_eq!( + parse_admin_post_search(r#""hello world"#), + ParsedAdminPostSearch { + mode: Some(AdminPostSearchMode::Keywords(vec![ + "hello".to_owned(), + "world".to_owned() + ])), + malformed_quotes: true, + } + ); + } + + #[tokio::test] + async fn admin_username_search_matches_username_handle_and_display_name() { + let (_temp, pool, settings) = test_pool().await; + let alice = register(&pool, &settings, "alice").await; + register(&pool, &settings, "bob").await; + pool.call(move |conn| { + conn.execute( + "UPDATE users SET display_name = 'Ada Admin' WHERE id = ?", + [alice], + )?; + Ok(()) + }) + .await + .expect("display name"); + + let by_handle = users(&pool, AdminUserSearch::new("@ali", "")) + .await + .expect("search by handle"); + let by_display = users(&pool, AdminUserSearch::new("ada", "")) + .await + .expect("search by display"); + + assert_eq!(usernames(&by_handle), vec!["alice"]); + assert!(by_handle[0].matched_name); + assert_eq!(usernames(&by_display), vec!["alice"]); + } + + #[tokio::test] + async fn admin_plain_post_keyword_search_matches_accounts_with_all_terms() { + let (_temp, pool, settings) = test_pool().await; + let alice = register(&pool, &settings, "alice").await; + let bob = register(&pool, &settings, "bob").await; + create_post(&pool, &settings, alice, "Rust admin investigation notes").await; + create_post(&pool, &settings, bob, "Rust release notes").await; + + let rows = users(&pool, AdminUserSearch::new("", "rust investigation")) + .await + .expect("post search"); + + assert_eq!(usernames(&rows), vec!["alice"]); + assert_eq!(rows[0].matching_post_count, 1); + assert_eq!( + rows[0].post_match_preview.as_deref(), + Some("Rust admin investigation notes") + ); + } + + #[tokio::test] + async fn admin_quoted_post_search_matches_exact_substring_phrase() { + let (_temp, pool, settings) = test_pool().await; + let alice = register(&pool, &settings, "alice").await; + let bob = register(&pool, &settings, "bob").await; + create_post(&pool, &settings, alice, "hello world from alice").await; + create_post(&pool, &settings, bob, "hello careful world from bob").await; + + let rows = users(&pool, AdminUserSearch::new("", r#""hello world""#)) + .await + .expect("phrase search"); + + assert_eq!(usernames(&rows), vec!["alice"]); + } + + #[tokio::test] + async fn admin_post_search_escapes_like_special_characters() { + let (_temp, pool, settings) = test_pool().await; + let alice = register(&pool, &settings, "alice").await; + let bob = register(&pool, &settings, "bob").await; + create_post(&pool, &settings, alice, "token 100%_safe").await; + create_post(&pool, &settings, bob, "token 1000 safe").await; + + let rows = users(&pool, AdminUserSearch::new("", "100%_safe")) + .await + .expect("escaped search"); + + assert_eq!(usernames(&rows), vec!["alice"]); + } + #[test] fn deep_settings_form_parsing_accepts_valid_values() { let settings = Settings::default(); diff --git a/src/render.rs b/src/render.rs index 9c64cfd..728a0ba 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1563,10 +1563,10 @@ nav button{border-color:transparent;background:transparent;color:var(--link-stro .thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} .timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px} .counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.post-permalink{font-weight:700;color:var(--link-strong)}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem}.noscript-banner{max-width:1100px;margin:.7rem auto 0;padding:.65rem .85rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);color:var(--muted-strong)}.js-enabled .noscript-banner{display:none} -.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} +.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} @media (max-width:1100px){.app-shell{--shell-side:220px;--shell-max:880px;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary))}.right-rail{display:none}} @media (max-width:820px){.app-shell{grid-template-columns:minmax(0,680px)}.left-rail,.right-rail{display:none}.mobile-nav{display:flex}} -@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} +@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} "#; #[cfg(test)] diff --git a/src/server.rs b/src/server.rs index a14398c..c811473 100644 --- a/src/server.rs +++ b/src/server.rs @@ -200,6 +200,12 @@ struct SearchQuery { q: Option, } +#[derive(Deserialize)] +struct AdminUsersQuery { + user_q: Option, + post_q: Option, +} + #[derive(Deserialize)] struct SettingsQuery { saved: Option, @@ -2546,35 +2552,27 @@ async fn admin_health( async fn admin_users( State(state): State>, headers: HeaderMap, + Query(query): Query, ) -> AppResult> { let user = require_admin(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let rows = admin::users(&state.pool).await?; - let list = rows - .into_iter() - .map(|(id, username, is_admin, suspended)| { - format!( - r#"{}{}{}{}{}"#, - id, - html_escape::encode_text(&username), - is_admin, - suspended, - small_form( - &format!("/admin/users/{id}/suspend"), - &csrf, - if suspended { "Unsuspend" } else { "Suspend" }, - if suspended { - "Unsuspend this account" - } else { - "Suspend this account" - }, - ) - ) - }) - .collect::>() - .join(""); + let user_query = query.user_q.unwrap_or_default(); + let post_query = query.post_q.unwrap_or_default(); + let search = admin::AdminUserSearch::new(&user_query, &post_query); + let malformed_quotes = search.post_search.malformed_quotes; + let has_filter = search.has_filter(); + let rows = admin::users(&state.pool, search).await?; + let quote_notice = if malformed_quotes { + r#"

Post search had unmatched quotes, so it was treated as plain keyword search.

"# + } else { + "" + }; + let list = admin_user_rows(&rows, &csrf, has_filter); let body = format!( - r#"

Users

{}
IDUsernameAdminSuspendedAction
"#, + r#"

Users

{}{}
"#, + html_escape::encode_double_quoted_attribute(&user_query), + html_escape::encode_double_quoted_attribute(&post_query), + quote_notice, list ); Ok(Html( @@ -2582,6 +2580,137 @@ async fn admin_users( )) } +fn admin_user_rows(rows: &[admin::AdminUserInvestigation], csrf: &str, searched: bool) -> String { + if rows.is_empty() { + let message = if searched { + "No users matched those filters." + } else { + "No users found." + }; + return format!( + r#"
{}

Try a different username, handle, display name, or post keyword.

"#, + html_escape::encode_text(message) + ); + } + + rows.iter() + .map(|row| admin_user_row(row, csrf, searched)) + .collect::>() + .join("") +} + +fn admin_user_row(row: &admin::AdminUserInvestigation, csrf: &str, searched: bool) -> String { + let display_name = if row.display_name.trim().is_empty() { + row.username.as_str() + } else { + row.display_name.as_str() + }; + let statuses = [ + if row.is_admin { "Admin" } else { "Member" }, + if row.is_suspended { + "Suspended" + } else { + "Active" + }, + if row.is_deleted { + "Deleted" + } else { + "Not deleted" + }, + ] + .iter() + .map(|status| { + format!( + r#"{}"#, + html_escape::encode_text(status) + ) + }) + .collect::>() + .join(""); + let match_labels = admin_user_match_labels(row, searched); + let preview = row + .post_match_preview + .as_ref() + .map_or_else(String::new, |text| { + format!( + r#"

Post match preview: {}

"#, + html_escape::encode_text(&short_preview(text)) + ) + }); + let action = small_form( + &format!("/admin/users/{}/suspend", row.id), + csrf, + if row.is_suspended { + "Unsuspend" + } else { + "Suspend" + }, + if row.is_suspended { + "Unsuspend this account" + } else { + "Suspend this account" + }, + ); + format!( + r#"
{} @{} #{}
{}
{}
Created
{}
Updated
{}
Last session
{}
Last post
{}
Total posts
{}
Uploaded media
{}
Reports on posts
{}
Moderation actions
{}
Matching posts
{}
{}
"#, + html_escape::encode_double_quoted_attribute(&row.username), + html_escape::encode_text(display_name), + html_escape::encode_text(&row.username), + row.id, + statuses, + match_labels, + html_escape::encode_text(&row.created_at), + html_escape::encode_text(&row.updated_at), + html_escape::encode_text(row.last_session_at.as_deref().unwrap_or("No session")), + html_escape::encode_text(row.last_post_at.as_deref().unwrap_or("No posts")), + row.total_posts, + row.uploaded_media_count, + row.reports_on_posts_count, + row.moderation_action_count, + row.matching_post_count, + preview, + action + ) +} + +fn admin_user_match_labels(row: &admin::AdminUserInvestigation, searched: bool) -> String { + if !searched { + return String::new(); + } + + let mut labels = Vec::new(); + if row.matched_name { + labels.push("Matched name".to_owned()); + } + if row.matching_post_count > 0 { + labels.push(format!("Matched post content: {}", row.matching_post_count)); + } + if labels.is_empty() { + return String::new(); + } + let labels = labels + .iter() + .map(|label| { + format!( + r#"{}"#, + html_escape::encode_text(label) + ) + }) + .collect::>() + .join(""); + format!(r#"
{labels}
"#) +} + +fn short_preview(text: &str) -> String { + const LIMIT: usize = 140; + let trimmed = text.trim(); + let mut preview = trimmed.chars().take(LIMIT).collect::(); + if trimmed.chars().count() > LIMIT { + preview.push_str("..."); + } + preview +} + async fn admin_suspend( State(state): State>, headers: HeaderMap, @@ -3116,6 +3245,7 @@ mod tests { struct TestServer { base_url: String, data_dir: PathBuf, + pool: SqlitePool, _task: tokio::task::JoinHandle<()>, _temp: tempfile::TempDir, } @@ -3220,6 +3350,244 @@ mod tests { ); } + #[tokio::test] + async fn non_admin_cannot_access_admin_users() { + let server = spawn_test_server_with_admin().await; + let member_cookie = register_test_user(&server, "member").await; + + let response = get_with_cookie(&server, "/admin/users", &member_cookie).await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn admin_users_page_shows_expanded_user_details() { + let server = spawn_test_server_with_admin().await; + let admin_cookie = admin_session_cookie(&server).await; + let settings = Settings::default(); + let alice = auth::register_user( + &server.pool, + &settings, + "alice", + "very secure password", + false, + ) + .await + .expect("alice"); + let reporter = auth::register_user( + &server.pool, + &settings, + "reporter", + "very secure password", + false, + ) + .await + .expect("reporter"); + let post = social::create_post( + &server.pool, + &settings, + Some(alice), + "expanded admin detail post", + None, + &[], + ) + .await + .expect("post"); + server + .pool + .call(move |conn| { + conn.execute( + "UPDATE users SET display_name = 'Alice Admin' WHERE id = ?", + [alice], + )?; + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'alice.png', '/tmp/alice.png', '/uploads/images/alice.png', 'image/png', 'image', 12)", + [alice], + )?; + conn.execute( + "INSERT INTO reports (reporter_user_id, post_id, reason) VALUES (?, ?, 'spam')", + params![reporter, post], + )?; + Ok(()) + }) + .await + .expect("seed details"); + + let response = get_with_cookie(&server, "/admin/users", &admin_cookie).await; + + assert_eq!(response.status, 200); + assert!(response.body.contains("Alice Admin")); + assert!(response.body.contains("@alice")); + assert!(response.body.contains("Created")); + assert!(response.body.contains("Last post")); + assert!(response.body.contains("Total posts")); + assert!(response.body.contains("Uploaded media")); + assert!(response.body.contains("Reports on posts")); + assert!(response.body.contains("Moderation actions")); + assert!(response.body.contains(">1")); + } + + #[tokio::test] + async fn admin_users_username_search_returns_expected_users() { + let server = spawn_test_server_with_admin().await; + let admin_cookie = admin_session_cookie(&server).await; + let settings = Settings::default(); + auth::register_user( + &server.pool, + &settings, + "alice", + "very secure password", + false, + ) + .await + .expect("alice"); + auth::register_user( + &server.pool, + &settings, + "bob", + "very secure password", + false, + ) + .await + .expect("bob"); + + let response = get_with_cookie(&server, "/admin/users?user_q=ali", &admin_cookie).await; + + assert_eq!(response.status, 200); + assert!(response.body.contains("@alice")); + assert!(!response.body.contains("@bob")); + assert!(response.body.contains("Matched name")); + } + + #[tokio::test] + async fn admin_users_post_keyword_search_returns_matching_accounts() { + let server = spawn_test_server_with_admin().await; + let admin_cookie = admin_session_cookie(&server).await; + let settings = Settings::default(); + let alice = auth::register_user( + &server.pool, + &settings, + "alice", + "very secure password", + false, + ) + .await + .expect("alice"); + let bob = auth::register_user( + &server.pool, + &settings, + "bob", + "very secure password", + false, + ) + .await + .expect("bob"); + social::create_post( + &server.pool, + &settings, + Some(alice), + "admin keyword needle - +
JavaScript is disabled. RustPost will use standard links and forms.
{}
{}
{}
{} alpha{}
@@ -155,6 +156,7 @@ pub fn layout_with_context( CSS, html_escape::encode_text(&brand_mark.to_string()), html_escape::encode_text(site_name), + header_tor, auth_nav, left_rail, body, @@ -164,6 +166,40 @@ pub fn layout_with_context( ) } +fn tor_header_indicator(onion: Option<&str>) -> String { + onion.map_or_else(String::new, |onion| { + let escaped_onion = html_escape::encode_text(onion); + let attr_onion = html_escape::encode_double_quoted_attribute(onion); + let short = short_onion_address(onion); + format!( + r#"
Tor mirror{}
{}
"#, + html_escape::encode_text(&short), + escaped_onion, + attr_onion, + ) + }) +} + +fn short_onion_address(onion: &str) -> String { + let (service_id, suffix_label) = onion + .strip_suffix(".onion") + .map_or((onion, ""), |service_id| (service_id, ".onion")); + let prefix: String = service_id.chars().take(6).collect(); + let suffix = service_id + .chars() + .rev() + .take(3) + .collect::>() + .into_iter() + .rev() + .collect::(); + if prefix.is_empty() || suffix.is_empty() || service_id.len() <= prefix.len() + suffix.len() { + onion.to_owned() + } else { + format!("{prefix}...{suffix}{suffix_label}") + } +} + fn nav_link(href: &str, label: &str, icon: &str) -> String { format!( r#"{}{}"#, @@ -589,6 +625,39 @@ document.addEventListener("submit", (event) => { if (submitter) { submitter.disabled = true; } +}); + +async function copyTextToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + document.body.append(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +} + +document.addEventListener("click", async (event) => { + const button = event.target.closest("[data-copy-text]"); + if (!button) { + return; + } + try { + await copyTextToClipboard(button.getAttribute("data-copy-text") || ""); + const original = button.getAttribute("data-copy-label") || button.textContent; + button.textContent = button.getAttribute("data-copied-label") || "Copied"; + window.setTimeout(() => { + button.textContent = original; + }, 1600); + } catch (_err) { + button.textContent = "Copy failed"; + } });"#; pub fn client_script() -> &'static str { @@ -1679,7 +1748,8 @@ const CSS: &str = r#" *{box-sizing:border-box}body{margin:0;min-width:320px;color:var(--text);background:var(--bg)}a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline} .site-header{position:sticky;top:0;z-index:10;background:var(--header-bg);border-bottom:1px solid var(--border);backdrop-filter:blur(8px)} .header-inner{max-width:var(--shell-max);margin:0 auto;padding:var(--header-padding-y) 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} -.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong)}.brand-mark{display:inline-grid;place-items:center;width:var(--header-brand-size);height:var(--header-brand-size);border-radius:7px;background:var(--brand);color:var(--brand-text)} +.header-brand-row{display:flex;align-items:center;gap:.75rem;min-width:0;max-width:100%}.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong);min-width:0}.brand span:last-child{overflow-wrap:anywhere}.brand-mark{display:inline-grid;place-items:center;width:var(--header-brand-size);height:var(--header-brand-size);border-radius:7px;background:var(--brand);color:var(--brand-text);flex:0 0 auto} +.tor-indicator{position:relative;min-width:0}.tor-indicator summary{display:inline-flex;align-items:center;gap:.4rem;min-height:2.15rem;border:1px solid var(--border);border-radius:999px;padding:.25rem .55rem;background:var(--surface-subtle);color:var(--link-strong);font-weight:800;cursor:pointer;list-style:none;max-width:100%}.tor-indicator summary::-webkit-details-marker{display:none}.tor-indicator summary:hover{background:var(--hover);text-decoration:none}.tor-dot{display:inline-grid;place-items:center;width:1.35rem;height:1.35rem;border-radius:999px;background:var(--brand);color:var(--brand-text);font-size:.75rem;font-weight:900;flex:0 0 auto}.tor-label,.tor-short{white-space:nowrap}.tor-short{color:var(--muted-strong);font-weight:700;font-size:.9rem}.tor-details{position:absolute;z-index:12;left:0;top:calc(100% + .35rem);display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.5rem;align-items:center;width:min(32rem,calc(100vw - 2rem));padding:.6rem;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);box-shadow:0 8px 24px var(--shadow)}.tor-details code{min-width:0;overflow-wrap:anywhere;word-break:break-all;color:var(--text-strong);font-size:.88rem}.tor-details button{min-height:2rem;padding:.35rem .65rem} nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}nav a,nav button,.button-link{display:inline-flex;align-items:center;gap:.35rem;min-height:2.15rem;border-radius:7px;padding:.42rem .65rem;color:var(--link-strong);border:1px solid transparent;background:transparent} nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline} nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} @@ -1701,7 +1771,7 @@ nav button{border-color:transparent;background:transparent;color:var(--link-stro .profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} @media (max-width:1100px){.app-shell{--shell-side:220px;--shell-max:880px;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary))}.right-rail{display:none}} @media (max-width:820px){.app-shell{grid-template-columns:minmax(0,680px)}.left-rail,.right-rail{display:none}.mobile-nav{display:flex}} -@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} +@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:flex-start;flex-direction:column;width:100%;gap:.55rem}.tor-indicator,.tor-indicator summary{width:100%}.tor-indicator summary{justify-content:flex-start}.tor-details{position:static;width:100%;margin-top:.4rem;grid-template-columns:1fr}.tor-details button{width:100%;justify-content:center}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} "#; #[cfg(test)] @@ -1895,6 +1965,7 @@ mod tests { ); assert!(!without_tor.contains("examplehiddenservice.onion")); assert!(!without_tor.contains("Onion: ")); + assert!(!without_tor.contains("tor-header-indicator")); let with_tor = layout_with_context( None, @@ -1908,7 +1979,25 @@ mod tests { }, ); assert!(with_tor.contains("examplehiddenservice.onion")); + assert!(with_tor.contains("tor-header-indicator")); + assert!(with_tor.contains("exampl...ice.onion")); + assert!(with_tor.contains(r#"data-copy-text="examplehiddenservice.onion""#)); assert!(with_tor.contains("footer-onion")); + assert!(with_tor.contains("Tor mirror: ")); + assert!(!with_tor.contains("Onion: ")); + } + + #[test] + fn short_onion_address_preserves_short_values() { + assert_eq!( + short_onion_address("abc.onion"), + "abc.onion", + "short or unusual values should remain readable" + ); + assert_eq!( + short_onion_address("abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion"), + "abcdef...vwx.onion" + ); } #[test] diff --git a/src/server.rs b/src/server.rs index bce3615..c155ab7 100644 --- a/src/server.rs +++ b/src/server.rs @@ -361,7 +361,10 @@ async fn layout_context( }; Ok(render::LayoutContext { anonymous_mode_enabled: state.settings.accounts.anonymous_mode_enabled, - tor_onion_address: state.tor.onion_address(), + tor_onion_address: state.tor.onion_address().or_else(|| { + (!state.settings.tor.display_onion_address.is_empty()) + .then(|| state.settings.tor.display_onion_address.clone()) + }), follower_count: counts.map(|(followers, _following)| followers), following_count: counts.map(|(_followers, following)| following), notification_unread_count, diff --git a/src/tor.rs b/src/tor.rs index 6ab12db..9f98b02 100644 --- a/src/tor.rs +++ b/src/tor.rs @@ -402,6 +402,7 @@ mod tests { tor_only, data_dir: "tor".to_owned(), onion_service_name: "microblog".to_owned(), + display_onion_address: String::new(), bootstrap_timeout_secs: 120, max_concurrent_streams: 512, include_tor_keys_in_backups_by_default: false, diff --git a/tests/playwright/nsfw-media.mjs b/tests/playwright/nsfw-media.mjs index 7913caa..c0fb965 100644 --- a/tests/playwright/nsfw-media.mjs +++ b/tests/playwright/nsfw-media.mjs @@ -7,6 +7,7 @@ import net from "node:net"; import { chromium, firefox, webkit } from "playwright"; const PASSWORD = "very secure password"; +const TEST_ONION_ADDRESS = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion"; const tinyPng = Buffer.from( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63f8cfc00000040001fea7699d160000000049454e44ae426082", "hex", @@ -28,7 +29,10 @@ async function startRustPost() { const port = await freePort(); runCargo(["run", "--quiet", "--bin", "rustpost-cli", "--", "--data-dir", dataDir, "init"]); const settingsPath = path.join(dataDir, "settings.toml"); - const settings = fs.readFileSync(settingsPath, "utf8").replace("port = 8080", `port = ${port}`); + const settings = fs + .readFileSync(settingsPath, "utf8") + .replace("port = 8080", `port = ${port}`) + .replace('display_onion_address = ""', `display_onion_address = "${TEST_ONION_ADDRESS}"`); fs.writeFileSync(settingsPath, settings); runCargo([ "run", @@ -63,7 +67,9 @@ async function jsChromiumFlow(baseUrl) { try { const user = await browser.newContext(); const page = await user.newPage(); + await installClipboardProbe(page); await register(page, baseUrl, "alicepw"); + await expectOnionHeaderWithJs(page, baseUrl); await createMediaPost(page, "alice flagged media", true); await expectBlurred(page); await page.locator(".nsfw-show").first().click(); @@ -98,9 +104,18 @@ async function jsChromiumFlow(baseUrl) { await anonPage.goto(`${baseUrl}/home`); await expectBlurred(anonPage); + const mobile = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + }); + const mobilePage = await mobile.newPage(); + await mobilePage.goto(`${baseUrl}/home`); + await expectMobileOnionLayout(mobilePage); + await user.close(); await admin.close(); await anonymous.close(); + await mobile.close(); } finally { await browser.close(); } @@ -112,6 +127,7 @@ async function noJsFirefoxFlow(baseUrl) { const context = await browser.newContext({ javaScriptEnabled: false }); const page = await context.newPage(); await register(page, baseUrl, "toruserpw"); + await expectOnionHeaderWithoutJs(page, baseUrl); await createMediaPost(page, "no js flagged media", true); await expectBlurred(page); await page.locator(".nsfw-show").first().click(); @@ -138,7 +154,9 @@ async function webKitFlow(baseUrl) { try { const context = await browser.newContext(); const page = await context.newPage(); + await installClipboardProbe(page); await register(page, baseUrl, "webkitpw"); + await expectOnionHeaderWithJs(page, baseUrl); await createMediaPost(page, "webkit flagged media", true); await expectBlurred(page); await page.locator(".nsfw-show").first().click(); @@ -149,6 +167,66 @@ async function webKitFlow(baseUrl) { } } +async function installClipboardProbe(page) { + await page.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + async writeText(text) { + window.__rustpostCopiedText = text; + }, + }, + }); + }); +} + +async function expectOnionHeaderWithJs(page, baseUrl) { + await page.goto(`${baseUrl}/home`); + const indicator = page.getByTestId("tor-header-indicator"); + await indicator.waitFor(); + const box = await indicator.boundingBox(); + assert.ok(box, "Tor header indicator should have a visible box"); + assert.ok(box.y < 180, `Tor header indicator should be near the top, got y=${box.y}`); + await indicator.locator("summary").click(); + await expectFullOnionAddress(page); + await page.getByTestId("tor-copy-button").click(); + assert.equal(await page.evaluate(() => window.__rustpostCopiedText), TEST_ONION_ADDRESS); + await page.getByRole("button", { name: "Copied" }).waitFor(); +} + +async function expectOnionHeaderWithoutJs(page, baseUrl) { + await page.goto(`${baseUrl}/home`); + const indicator = page.getByTestId("tor-header-indicator"); + await indicator.waitFor(); + await indicator.locator("summary").click(); + await expectFullOnionAddress(page); + assert.equal(await page.getByTestId("tor-copy-button").innerText(), "Copy"); +} + +async function expectFullOnionAddress(page) { + const fullAddress = page.getByTestId("tor-full-address"); + await fullAddress.waitFor(); + assert.equal(await fullAddress.innerText(), TEST_ONION_ADDRESS); +} + +async function expectMobileOnionLayout(page) { + const indicator = page.getByTestId("tor-header-indicator"); + await indicator.waitFor(); + const viewport = page.viewportSize(); + const box = await indicator.boundingBox(); + assert.ok(box, "mobile Tor header indicator should have a visible box"); + assert.ok(box.y < 180, `mobile Tor header indicator should be near the top, got y=${box.y}`); + assert.ok(box.x >= 0, `mobile Tor header indicator should not overflow left, got x=${box.x}`); + assert.ok( + box.x + box.width <= viewport.width, + `mobile Tor header indicator should fit viewport, got right=${box.x + box.width}`, + ); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + assert.ok(overflow <= 1, `mobile layout should not overflow horizontally, got ${overflow}px`); + await indicator.locator("summary").click(); + await expectFullOnionAddress(page); +} + async function register(page, baseUrl, username) { await page.goto(`${baseUrl}/register`); await page.locator("#username").fill(username); From 1c85253ca753be680713d0436a3eb80e1dcfd9ac Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 22 May 2026 18:05:15 -0700 Subject: [PATCH 13/23] Modify .gitignore for playwright files Update .gitignore to include playwright directory and specific file. --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 8163438..0e14828 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,9 @@ trace.zip *.trace *.har tests/* -!tests/playwright/ +tests/playwright/ tests/playwright/* -!tests/playwright/nsfw-media.mjs +tests/playwright/nsfw-media.mjs package-lock.json package.json playwright.config.ts From 612ce2d8c0438d1d3625c73444d3e579fe945d6a Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 22 May 2026 18:05:35 -0700 Subject: [PATCH 14/23] Delete tests/playwright directory --- tests/playwright/nsfw-media.mjs | 339 -------------------------------- 1 file changed, 339 deletions(-) delete mode 100644 tests/playwright/nsfw-media.mjs diff --git a/tests/playwright/nsfw-media.mjs b/tests/playwright/nsfw-media.mjs deleted file mode 100644 index c0fb965..0000000 --- a/tests/playwright/nsfw-media.mjs +++ /dev/null @@ -1,339 +0,0 @@ -import assert from "node:assert/strict"; -import { spawn, spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import net from "node:net"; -import { chromium, firefox, webkit } from "playwright"; - -const PASSWORD = "very secure password"; -const TEST_ONION_ADDRESS = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion"; -const tinyPng = Buffer.from( - "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63f8cfc00000040001fea7699d160000000049454e44ae426082", - "hex", -); - -async function main() { - const server = await startRustPost(); - try { - await jsChromiumFlow(server.baseUrl); - await noJsFirefoxFlow(server.baseUrl); - await webKitFlow(server.baseUrl); - } finally { - server.stop(); - } -} - -async function startRustPost() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "rustpost-pw-")); - const port = await freePort(); - runCargo(["run", "--quiet", "--bin", "rustpost-cli", "--", "--data-dir", dataDir, "init"]); - const settingsPath = path.join(dataDir, "settings.toml"); - const settings = fs - .readFileSync(settingsPath, "utf8") - .replace("port = 8080", `port = ${port}`) - .replace('display_onion_address = ""', `display_onion_address = "${TEST_ONION_ADDRESS}"`); - fs.writeFileSync(settingsPath, settings); - runCargo([ - "run", - "--quiet", - "--bin", - "rustpost-cli", - "--", - "--data-dir", - dataDir, - "create-admin", - "siteowner", - PASSWORD, - ]); - const child = spawn( - "cargo", - ["run", "--quiet", "--bin", "rustpost-cli", "--", "--data-dir", dataDir, "serve"], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - const baseUrl = `http://127.0.0.1:${port}`; - await waitForServer(baseUrl, child); - return { - baseUrl, - stop() { - child.kill("SIGTERM"); - fs.rmSync(dataDir, { recursive: true, force: true }); - }, - }; -} - -async function jsChromiumFlow(baseUrl) { - const browser = await chromium.launch(); - try { - const user = await browser.newContext(); - const page = await user.newPage(); - await installClipboardProbe(page); - await register(page, baseUrl, "alicepw"); - await expectOnionHeaderWithJs(page, baseUrl); - await createMediaPost(page, "alice flagged media", true); - await expectBlurred(page); - await page.locator(".nsfw-show").first().click(); - await expectUnblurred(page); - - await page.goto(`${baseUrl}/settings`); - await page.locator("#nsfw_blur_enabled").uncheck(); - await page.getByRole("button", { name: "Save settings" }).click(); - await page.goto(`${baseUrl}/home`); - await assertNoBlurredMedia(page); - await page.goto(`${baseUrl}/settings`); - await page.locator("#nsfw_blur_enabled").check(); - await page.getByRole("button", { name: "Save settings" }).click(); - await page.goto(`${baseUrl}/home`); - await expectBlurred(page); - - const admin = await browser.newContext(); - const adminPage = await admin.newPage(); - await login(adminPage, baseUrl, "siteowner", PASSWORD); - await createMediaPost(adminPage, "admin toggle media", false); - await adminPage.getByRole("button", { name: "Mark NSFW" }).first().click(); - await expectPostBlurred(adminPage, "admin toggle media"); - await adminPage.getByRole("button", { name: "Unmark NSFW" }).first().click(); - await assertPostNotBlurred(adminPage, "admin toggle media"); - - await setGlobalBlur(adminPage, false); - await page.goto(`${baseUrl}/home`); - await assertNoBlurredMedia(page); - await setGlobalBlur(adminPage, true); - const anonymous = await browser.newContext(); - const anonPage = await anonymous.newPage(); - await anonPage.goto(`${baseUrl}/home`); - await expectBlurred(anonPage); - - const mobile = await browser.newContext({ - viewport: { width: 390, height: 844 }, - isMobile: true, - }); - const mobilePage = await mobile.newPage(); - await mobilePage.goto(`${baseUrl}/home`); - await expectMobileOnionLayout(mobilePage); - - await user.close(); - await admin.close(); - await anonymous.close(); - await mobile.close(); - } finally { - await browser.close(); - } -} - -async function noJsFirefoxFlow(baseUrl) { - const browser = await firefox.launch(); - try { - const context = await browser.newContext({ javaScriptEnabled: false }); - const page = await context.newPage(); - await register(page, baseUrl, "toruserpw"); - await expectOnionHeaderWithoutJs(page, baseUrl); - await createMediaPost(page, "no js flagged media", true); - await expectBlurred(page); - await page.locator(".nsfw-show").first().click(); - await expectUnblurred(page); - - const adminContext = await browser.newContext({ javaScriptEnabled: false }); - const adminPage = await adminContext.newPage(); - await login(adminPage, baseUrl, "siteowner", PASSWORD); - await createMediaPost(adminPage, "no js admin media", false); - await adminPage.getByRole("button", { name: "Mark NSFW" }).first().click(); - await expectPostBlurred(adminPage, "no js admin media"); - await adminPage.getByRole("button", { name: "Unmark NSFW" }).first().click(); - await assertPostNotBlurred(adminPage, "no js admin media"); - - await context.close(); - await adminContext.close(); - } finally { - await browser.close(); - } -} - -async function webKitFlow(baseUrl) { - const browser = await webkit.launch(); - try { - const context = await browser.newContext(); - const page = await context.newPage(); - await installClipboardProbe(page); - await register(page, baseUrl, "webkitpw"); - await expectOnionHeaderWithJs(page, baseUrl); - await createMediaPost(page, "webkit flagged media", true); - await expectBlurred(page); - await page.locator(".nsfw-show").first().click(); - await expectUnblurred(page); - await context.close(); - } finally { - await browser.close(); - } -} - -async function installClipboardProbe(page) { - await page.addInitScript(() => { - Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: { - async writeText(text) { - window.__rustpostCopiedText = text; - }, - }, - }); - }); -} - -async function expectOnionHeaderWithJs(page, baseUrl) { - await page.goto(`${baseUrl}/home`); - const indicator = page.getByTestId("tor-header-indicator"); - await indicator.waitFor(); - const box = await indicator.boundingBox(); - assert.ok(box, "Tor header indicator should have a visible box"); - assert.ok(box.y < 180, `Tor header indicator should be near the top, got y=${box.y}`); - await indicator.locator("summary").click(); - await expectFullOnionAddress(page); - await page.getByTestId("tor-copy-button").click(); - assert.equal(await page.evaluate(() => window.__rustpostCopiedText), TEST_ONION_ADDRESS); - await page.getByRole("button", { name: "Copied" }).waitFor(); -} - -async function expectOnionHeaderWithoutJs(page, baseUrl) { - await page.goto(`${baseUrl}/home`); - const indicator = page.getByTestId("tor-header-indicator"); - await indicator.waitFor(); - await indicator.locator("summary").click(); - await expectFullOnionAddress(page); - assert.equal(await page.getByTestId("tor-copy-button").innerText(), "Copy"); -} - -async function expectFullOnionAddress(page) { - const fullAddress = page.getByTestId("tor-full-address"); - await fullAddress.waitFor(); - assert.equal(await fullAddress.innerText(), TEST_ONION_ADDRESS); -} - -async function expectMobileOnionLayout(page) { - const indicator = page.getByTestId("tor-header-indicator"); - await indicator.waitFor(); - const viewport = page.viewportSize(); - const box = await indicator.boundingBox(); - assert.ok(box, "mobile Tor header indicator should have a visible box"); - assert.ok(box.y < 180, `mobile Tor header indicator should be near the top, got y=${box.y}`); - assert.ok(box.x >= 0, `mobile Tor header indicator should not overflow left, got x=${box.x}`); - assert.ok( - box.x + box.width <= viewport.width, - `mobile Tor header indicator should fit viewport, got right=${box.x + box.width}`, - ); - const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); - assert.ok(overflow <= 1, `mobile layout should not overflow horizontally, got ${overflow}px`); - await indicator.locator("summary").click(); - await expectFullOnionAddress(page); -} - -async function register(page, baseUrl, username) { - await page.goto(`${baseUrl}/register`); - await page.locator("#username").fill(username); - await page.locator("#password").fill(PASSWORD); - await page.locator("#confirm_password").fill(PASSWORD); - await page.getByRole("button", { name: "Create account" }).click(); - await page.waitForURL("**/home"); -} - -async function login(page, baseUrl, username, password) { - await page.goto(`${baseUrl}/login`); - await page.locator("#username").fill(username); - await page.locator("#password").fill(password); - await page.getByRole("button", { name: "Log in" }).click(); - await page.waitForURL("**/home"); -} - -async function createMediaPost(page, text, nsfw) { - await page.goto(new URL("/home", page.url()).toString()); - await page.locator("#post-text").fill(text); - if (nsfw) { - await page.locator("#post-nsfw").check(); - } - await page.locator("#media").setInputFiles({ - name: `${text.replaceAll(" ", "-")}.png`, - mimeType: "image/png", - buffer: tinyPng, - }); - await page.getByRole("button", { name: "Post", exact: true }).click(); - await page.getByText(text).waitFor(); -} - -async function setGlobalBlur(page, enabled) { - await page.goto(new URL("/admin/deep-settings", page.url()).toString()); - await page.locator("#deep-nsfw_blur_enabled").selectOption(enabled ? "true" : "false"); - await page.getByRole("button", { name: "Save" }).click(); - await page.getByRole("button", { name: "Confirm/Save" }).click(); - await page.getByText("Settings saved successfully").waitFor(); -} - -async function expectBlurred(page) { - await page.locator(".nsfw-media").first().waitFor(); - const filter = await page.locator(".nsfw-media-frame img").first().evaluate((node) => getComputedStyle(node).filter); - assert.notEqual(filter, "none"); -} - -async function expectUnblurred(page) { - const filter = await page.locator(".nsfw-media-frame img").first().evaluate((node) => getComputedStyle(node).filter); - assert.equal(filter, "none"); -} - -async function assertNoBlurredMedia(page) { - await page.waitForLoadState("domcontentloaded"); - assert.equal(await page.locator(".nsfw-media").count(), 0); -} - -async function expectPostBlurred(page, text) { - const post = page.locator("article", { hasText: text }).first(); - await post.locator(".nsfw-media").first().waitFor(); -} - -async function assertPostNotBlurred(page, text) { - const post = page.locator("article", { hasText: text }).first(); - assert.equal(await post.locator(".nsfw-media").count(), 0); -} - -function runCargo(args) { - const result = spawnSync("cargo", args, { encoding: "utf8" }); - if (result.status !== 0) { - throw new Error(`cargo ${args.join(" ")} failed\n${result.stderr}\n${result.stdout}`); - } -} - -async function freePort() { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - server.close(() => resolve(address.port)); - }); - server.on("error", reject); - }); -} - -async function waitForServer(baseUrl, child) { - let stderr = ""; - child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - if (child.exitCode !== null) { - throw new Error(`RustPost exited early\n${stderr}`); - } - try { - const response = await fetch(`${baseUrl}/home`); - if (response.ok) { - return; - } - } catch (_err) { - await new Promise((resolve) => setTimeout(resolve, 250)); - } - } - throw new Error(`RustPost did not start\n${stderr}`); -} - -main().catch((error) => { - console.error(error); - process.exitCode = 1; -}); From 0c7784b1d05be4b3f8507ba7ed3c66e871b2d573 Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 22 May 2026 18:21:55 -0700 Subject: [PATCH 15/23] Update registration_captcha.rs --- src/registration_captcha.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registration_captcha.rs b/src/registration_captcha.rs index f6c067e..34592af 100644 --- a/src/registration_captcha.rs +++ b/src/registration_captcha.rs @@ -14,6 +14,7 @@ use uuid::Uuid; const CAPTCHA_CHARS: &[u8] = b"123456789ABCDEFGHJKMNPQRSTUVWXYZ"; const CAPTCHA_LENGTH: usize = 5; const CAPTCHA_TTL: Duration = Duration::from_secs(10 * 60); +#[cfg(debug_assertions)] const CAPTCHA_E2E_ANSWER_ENV: &str = "RUSTPOST_E2E_CAPTCHA_ANSWER"; #[derive(Debug, Clone)] From 48014715dddce811958b45dc1c7599cc5419b09c Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 22 May 2026 18:25:57 -0700 Subject: [PATCH 16/23] Update Cargo.lock --- Cargo.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ede29ea..7f7d5df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,9 +336,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -502,9 +502,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "by_address" @@ -1190,9 +1190,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -2106,9 +2106,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -3421,9 +3421,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -5531,9 +5531,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -5544,9 +5544,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5554,9 +5554,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -5567,9 +5567,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] From 17e222c5fa7f0188d5bd0d5ec49e4ac0c9c54a57 Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 22 May 2026 19:22:48 -0700 Subject: [PATCH 17/23] Update render.rs --- src/render.rs | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/render.rs b/src/render.rs index 199112a..0618c4b 100644 --- a/src/render.rs +++ b/src/render.rs @@ -119,15 +119,6 @@ pub fn layout_with_context( let side_panel = dashboard_panel(user, context); let theme = user.map_or(Theme::Light, |user| user.theme).as_str(); let header_tor = tor_header_indicator(context.tor_onion_address.as_deref()); - let footer_onion = context - .tor_onion_address - .as_deref() - .map_or_else(String::new, |onion| { - format!( - r#" Tor mirror: {}"#, - html_escape::encode_text(onion) - ) - }); format!( r#" @@ -146,7 +137,7 @@ pub fn layout_with_context(
JavaScript is disabled. RustPost will use standard links and forms.
{}
{}
{}
-
{} alpha{}
+
{} alpha
"#, html_escape::encode_double_quoted_attribute(theme), @@ -162,7 +153,6 @@ pub fn layout_with_context( body, side_panel, html_escape::encode_text(site_name), - footer_onion ) } @@ -172,7 +162,7 @@ fn tor_header_indicator(onion: Option<&str>) -> String { let attr_onion = html_escape::encode_double_quoted_attribute(onion); let short = short_onion_address(onion); format!( - r#"
Tor mirror{}
{}
"#, + r#"
Tor mirror: {}
{}
"#, html_escape::encode_text(&short), escaped_onion, attr_onion, @@ -1749,12 +1739,12 @@ const CSS: &str = r#" .site-header{position:sticky;top:0;z-index:10;background:var(--header-bg);border-bottom:1px solid var(--border);backdrop-filter:blur(8px)} .header-inner{max-width:var(--shell-max);margin:0 auto;padding:var(--header-padding-y) 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} .header-brand-row{display:flex;align-items:center;gap:.75rem;min-width:0;max-width:100%}.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong);min-width:0}.brand span:last-child{overflow-wrap:anywhere}.brand-mark{display:inline-grid;place-items:center;width:var(--header-brand-size);height:var(--header-brand-size);border-radius:7px;background:var(--brand);color:var(--brand-text);flex:0 0 auto} -.tor-indicator{position:relative;min-width:0}.tor-indicator summary{display:inline-flex;align-items:center;gap:.4rem;min-height:2.15rem;border:1px solid var(--border);border-radius:999px;padding:.25rem .55rem;background:var(--surface-subtle);color:var(--link-strong);font-weight:800;cursor:pointer;list-style:none;max-width:100%}.tor-indicator summary::-webkit-details-marker{display:none}.tor-indicator summary:hover{background:var(--hover);text-decoration:none}.tor-dot{display:inline-grid;place-items:center;width:1.35rem;height:1.35rem;border-radius:999px;background:var(--brand);color:var(--brand-text);font-size:.75rem;font-weight:900;flex:0 0 auto}.tor-label,.tor-short{white-space:nowrap}.tor-short{color:var(--muted-strong);font-weight:700;font-size:.9rem}.tor-details{position:absolute;z-index:12;left:0;top:calc(100% + .35rem);display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.5rem;align-items:center;width:min(32rem,calc(100vw - 2rem));padding:.6rem;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);box-shadow:0 8px 24px var(--shadow)}.tor-details code{min-width:0;overflow-wrap:anywhere;word-break:break-all;color:var(--text-strong);font-size:.88rem}.tor-details button{min-height:2rem;padding:.35rem .65rem} +.tor-indicator{position:relative;min-width:0;flex:0 1 auto}.tor-indicator summary{display:inline-flex;align-items:center;min-height:2.15rem;border:1px solid var(--border);border-radius:999px;padding:.25rem .6rem;background:var(--surface-subtle);color:var(--link-strong);font-weight:800;cursor:pointer;list-style:none;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tor-indicator summary::-webkit-details-marker{display:none}.tor-indicator summary:hover{background:var(--hover);text-decoration:none}.tor-summary-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tor-details{position:absolute;z-index:12;left:0;top:calc(100% + .35rem);display:flex;gap:.4rem;align-items:center;width:max-content;max-width:calc(100vw - 2rem);padding:.25rem .35rem;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);box-shadow:0 8px 24px var(--shadow);white-space:nowrap}.tor-details code{min-width:0;max-width:min(28rem,calc(100vw - 7rem));overflow-x:auto;overflow-y:hidden;white-space:nowrap;color:var(--text-strong);font-size:.88rem;line-height:1.2}.tor-details button{flex:0 0 auto;min-height:1.6rem;padding:.12rem .45rem;line-height:1.2} nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}nav a,nav button,.button-link{display:inline-flex;align-items:center;gap:.35rem;min-height:2.15rem;border-radius:7px;padding:.42rem .65rem;color:var(--link-strong);border:1px solid transparent;background:transparent} nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline} nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} .nav-badge{display:inline-grid;place-items:center;min-width:1.25rem;height:1.25rem;border-radius:999px;padding:0 .35rem;background:var(--brand);color:var(--brand-text);font-size:.78rem;font-weight:800;line-height:1} -main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:var(--rail-sticky-top);display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}.footer-onion{display:block;margin-top:.25rem;overflow-wrap:anywhere} +main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:var(--rail-sticky-top);display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem} .page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:.85rem;box-shadow:0 1px 2px var(--shadow)} .page-header h1,.section-heading h1,.panel h1{margin:0;font-size:1.45rem;line-height:1.2}.panel h1+table,.panel h1+form,.panel h1+p,.panel h1+dl{margin-top:.85rem}.page-header p,.muted,.empty-state p{color:var(--muted);margin:.35rem 0 0}.section-heading{display:flex;justify-content:space-between;gap:1rem;align-items:baseline;margin-bottom:.8rem} .notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 .7rem;padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notifications-list{display:grid;gap:.5rem}.notification-group{margin:.8rem .15rem .2rem;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.js-enabled .notification-row[data-card-href]{cursor:pointer}.js-enabled .notification-row[data-card-href]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem} @@ -1767,11 +1757,11 @@ nav button{border-color:transparent;background:transparent;color:var(--link-stro .composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem}.composer-media-controls{display:flex;align-items:center;gap:.8rem;flex-wrap:wrap}.composer-nsfw{margin:0}.file-control{display:inline-flex;align-items:center;gap:.6rem;margin:0;color:var(--link-strong);font-weight:700}.file-control input{max-width:15rem} .thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} .timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px}.nsfw-media{position:relative;margin-top:.5rem}.nsfw-media .post img,.nsfw-media .post video{margin-top:0}.nsfw-media-frame{position:relative;display:block;overflow:hidden;border-radius:8px}.nsfw-media-frame img,.nsfw-media-frame video{margin-top:0;filter:blur(24px);transform:scale(1.02)}.nsfw-toggle:checked+.nsfw-media-frame img,.nsfw-toggle:checked+.nsfw-media-frame video{filter:none;transform:none}.nsfw-badge{position:absolute;left:.55rem;bottom:.55rem;border-radius:999px;padding:.18rem .5rem;background:rgba(0,0,0,.72);color:#fff;font-size:.78rem;font-weight:900;letter-spacing:.03em}.nsfw-show{position:absolute;right:.55rem;bottom:.55rem;margin:0;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text-strong);padding:.32rem .65rem;font-weight:900;box-shadow:0 1px 2px var(--shadow);cursor:pointer}.nsfw-show:hover{background:var(--hover)}.nsfw-toggle:focus-visible~.nsfw-show{outline:3px solid var(--focus);outline-offset:2px}.nsfw-toggle:checked~.nsfw-show,.nsfw-toggle:checked+.nsfw-media-frame .nsfw-badge{display:none}.nsfw-open{display:block;margin:.35rem 0 0;font-size:.9rem;font-weight:700} -.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.post-permalink{font-weight:700;color:var(--link-strong)}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.admin-nsfw-button{min-height:2.2rem;padding:.3rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border);font-size:.86rem}.admin-nsfw-button:hover{background:var(--hover);color:var(--text-strong)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem}.noscript-banner{max-width:1100px;margin:.7rem auto 0;padding:.65rem .85rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);color:var(--muted-strong)}.js-enabled .noscript-banner{display:none} +.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.post-permalink{font-weight:700;color:var(--link-strong)}.js-enabled .post-permalink{display:none}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.admin-nsfw-button{min-height:2.2rem;padding:.3rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border);font-size:.86rem}.admin-nsfw-button:hover{background:var(--hover);color:var(--text-strong)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem}.noscript-banner{max-width:1100px;margin:.7rem auto 0;padding:.65rem .85rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);color:var(--muted-strong)}.js-enabled .noscript-banner{display:none} .profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} @media (max-width:1100px){.app-shell{--shell-side:220px;--shell-max:880px;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary))}.right-rail{display:none}} @media (max-width:820px){.app-shell{grid-template-columns:minmax(0,680px)}.left-rail,.right-rail{display:none}.mobile-nav{display:flex}} -@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:flex-start;flex-direction:column;width:100%;gap:.55rem}.tor-indicator,.tor-indicator summary{width:100%}.tor-indicator summary{justify-content:flex-start}.tor-details{position:static;width:100%;margin-top:.4rem;grid-template-columns:1fr}.tor-details button{width:100%;justify-content:center}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} +@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:center;width:100%;gap:.55rem}.tor-indicator{max-width:calc(100% - 7rem);margin-left:auto}.tor-details{left:auto;right:0;max-width:calc(100vw - 1.5rem)}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} "#; #[cfg(test)] @@ -1818,6 +1808,7 @@ mod tests { assert!(body.contains(r#"class="noscript-banner" role="status""#)); assert!(body.contains(".js-enabled .noscript-banner")); + assert!(body.contains(".js-enabled .post-permalink")); assert!(body.contains("display:none")); assert!( client_script().contains(r#"document.documentElement.classList.add("js-enabled")"#) @@ -1980,10 +1971,11 @@ mod tests { ); assert!(with_tor.contains("examplehiddenservice.onion")); assert!(with_tor.contains("tor-header-indicator")); + assert!(with_tor.contains("tor-summary-text")); assert!(with_tor.contains("exampl...ice.onion")); assert!(with_tor.contains(r#"data-copy-text="examplehiddenservice.onion""#)); - assert!(with_tor.contains("footer-onion")); - assert!(with_tor.contains("Tor mirror: ")); + assert!(!with_tor.contains("footer-onion")); + assert!(!with_tor.contains("Tor mirror: ")); assert!(!with_tor.contains("Onion: ")); } From f3a4e3ce10ebacf05ceb5f18ffca855804f4bea1 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 23 May 2026 12:47:47 -0700 Subject: [PATCH 18/23] Refine Tor header into compact status link --- src/render.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/render.rs b/src/render.rs index 0618c4b..47d4bdb 100644 --- a/src/render.rs +++ b/src/render.rs @@ -158,13 +158,16 @@ pub fn layout_with_context( fn tor_header_indicator(onion: Option<&str>) -> String { onion.map_or_else(String::new, |onion| { - let escaped_onion = html_escape::encode_text(onion); let attr_onion = html_escape::encode_double_quoted_attribute(onion); + let onion_url = format!("http://{onion}"); + let attr_onion_url = html_escape::encode_double_quoted_attribute(&onion_url); let short = short_onion_address(onion); format!( - r#"
Tor mirror: {}
{}
"#, + r#"
Tor:{}
"#, + attr_onion_url, + attr_onion, + attr_onion, html_escape::encode_text(&short), - escaped_onion, attr_onion, ) }) @@ -1739,7 +1742,7 @@ const CSS: &str = r#" .site-header{position:sticky;top:0;z-index:10;background:var(--header-bg);border-bottom:1px solid var(--border);backdrop-filter:blur(8px)} .header-inner{max-width:var(--shell-max);margin:0 auto;padding:var(--header-padding-y) 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem} .header-brand-row{display:flex;align-items:center;gap:.75rem;min-width:0;max-width:100%}.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong);min-width:0}.brand span:last-child{overflow-wrap:anywhere}.brand-mark{display:inline-grid;place-items:center;width:var(--header-brand-size);height:var(--header-brand-size);border-radius:7px;background:var(--brand);color:var(--brand-text);flex:0 0 auto} -.tor-indicator{position:relative;min-width:0;flex:0 1 auto}.tor-indicator summary{display:inline-flex;align-items:center;min-height:2.15rem;border:1px solid var(--border);border-radius:999px;padding:.25rem .6rem;background:var(--surface-subtle);color:var(--link-strong);font-weight:800;cursor:pointer;list-style:none;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tor-indicator summary::-webkit-details-marker{display:none}.tor-indicator summary:hover{background:var(--hover);text-decoration:none}.tor-summary-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tor-details{position:absolute;z-index:12;left:0;top:calc(100% + .35rem);display:flex;gap:.4rem;align-items:center;width:max-content;max-width:calc(100vw - 2rem);padding:.25rem .35rem;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);box-shadow:0 8px 24px var(--shadow);white-space:nowrap}.tor-details code{min-width:0;max-width:min(28rem,calc(100vw - 7rem));overflow-x:auto;overflow-y:hidden;white-space:nowrap;color:var(--text-strong);font-size:.88rem;line-height:1.2}.tor-details button{flex:0 0 auto;min-height:1.6rem;padding:.12rem .45rem;line-height:1.2} +.tor-indicator{display:inline-flex;align-items:center;gap:.28rem;min-width:0;max-width:min(28rem,100%);flex:1 1 14rem;min-height:2rem;border-left:1px solid var(--border);padding:.05rem 0 .05rem .7rem;color:var(--muted-strong);font-size:.86rem;line-height:1;white-space:nowrap;overflow:hidden}.tor-label{flex:0 0 auto;color:var(--muted);font-weight:800}.tor-address-link{display:inline-flex;align-items:center;min-width:0;max-width:100%;flex:1 1 auto;min-height:1.9rem;padding:0 .1rem;color:var(--link-strong);font-weight:750}.tor-address-link:hover{color:var(--text-strong);text-decoration:underline}.tor-summary-text{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}.tor-copy-button{display:none;flex:0 0 auto;min-width:2.55rem;min-height:1.9rem;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--muted-strong);padding:.18rem .35rem;font-size:.82rem;font-weight:800;line-height:1;box-shadow:none}.js-enabled .tor-copy-button{display:inline-flex;align-items:center;justify-content:center}.tor-copy-button:hover{background:var(--hover);color:var(--link-strong)} nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}nav a,nav button,.button-link{display:inline-flex;align-items:center;gap:.35rem;min-height:2.15rem;border-radius:7px;padding:.42rem .65rem;color:var(--link-strong);border:1px solid transparent;background:transparent} nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline} nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto} @@ -1972,9 +1975,16 @@ mod tests { assert!(with_tor.contains("examplehiddenservice.onion")); assert!(with_tor.contains("tor-header-indicator")); assert!(with_tor.contains("tor-summary-text")); + assert!(with_tor.contains(r#"Tor:"#)); + assert!(with_tor.contains(r#"href="http://examplehiddenservice.onion""#)); + assert!(with_tor.contains(r#"title="Open Tor mirror: examplehiddenservice.onion""#)); + assert!(with_tor.contains(r#"aria-label="Open Tor mirror at examplehiddenservice.onion""#)); assert!(with_tor.contains("exampl...ice.onion")); assert!(with_tor.contains(r#"data-copy-text="examplehiddenservice.onion""#)); + assert!(with_tor.contains(r#"aria-label="Copy Tor onion address""#)); assert!(!with_tor.contains("footer-onion")); + assert!(!with_tor.contains("")); assert!(!with_tor.contains("Onion: ")); } From 866db389e3c583ca7e85895cb73d35c1887da4b6 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 23 May 2026 17:36:45 -0700 Subject: [PATCH 19/23] Refine composer media UI and fix Clippy lints --- .gitignore | 1 - src/registration_captcha.rs | 11 +++-- src/render.rs | 86 ++++++++++++++++++++++++++++++-- src/server.rs | 97 +++++++++++++++++++++---------------- 4 files changed, 145 insertions(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index 0e14828..32e4e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,3 @@ uploads/**/* !/tests/ !/tests/playwright/ /tests/playwright/* -!/tests/playwright/nsfw-media.mjs diff --git a/src/registration_captcha.rs b/src/registration_captcha.rs index 34592af..1b75419 100644 --- a/src/registration_captcha.rs +++ b/src/registration_captcha.rs @@ -101,10 +101,12 @@ impl RegistrationCaptchaStore { .filter(|value| !value.is_empty()) .ok_or(RegistrationCaptchaError::MissingAnswer)?; - let mut challenges = self.challenges.lock().await; let now = Instant::now(); - remove_expired(&mut challenges, now); - let Some(challenge) = challenges.remove(token) else { + let Some(challenge) = ({ + let mut challenges = self.challenges.lock().await; + remove_expired(&mut challenges, now); + challenges.remove(token) + }) else { return Err(RegistrationCaptchaError::ExpiredOrUsed); }; if challenge.expires_at <= now { @@ -121,7 +123,8 @@ impl RegistrationCaptchaStore { pub async fn expire_for_test(&self, token: &str) { let mut challenges = self.challenges.lock().await; if let Some(challenge) = challenges.get_mut(token) { - challenge.expires_at = Instant::now() - Duration::from_secs(1); + let now = Instant::now(); + challenge.expires_at = now.checked_sub(Duration::from_secs(1)).unwrap_or(now); } } } diff --git a/src/render.rs b/src/render.rs index 47d4bdb..c598c6a 100644 --- a/src/render.rs +++ b/src/render.rs @@ -460,6 +460,56 @@ document.querySelectorAll("textarea[data-character-limit]").forEach((textarea) = textarea.addEventListener("input", () => updateComposerCount(textarea)); }); +function mediaSummary(input) { + if (!input.files || input.files.length === 0) { + return ""; + } + if (input.files.length === 1) { + return input.files[0].name || "1 media file selected"; + } + return `${input.files.length} media files selected`; +} + +function updateComposerMedia(input) { + const form = input.closest("form"); + if (!form) { + return; + } + const hasMedia = !!(input.files && input.files.length > 0); + const selection = form.querySelector("[data-composer-media-selection]"); + const summary = form.querySelector("[data-composer-media-summary]"); + const nsfw = form.querySelector("[data-composer-nsfw]"); + if (summary) { + summary.textContent = mediaSummary(input); + } + if (selection) { + selection.hidden = !hasMedia; + } + if (nsfw && !hasMedia) { + nsfw.checked = false; + } +} + +document.querySelectorAll("input[type=file][data-composer-media]").forEach((input) => { + updateComposerMedia(input); + input.addEventListener("change", () => updateComposerMedia(input)); +}); + +document.addEventListener("click", (event) => { + const button = event.target.closest("[data-composer-clear-media]"); + if (!button) { + return; + } + const form = button.closest("form"); + const input = form ? form.querySelector("input[type=file][data-composer-media]") : null; + if (!input) { + return; + } + input.value = ""; + updateComposerMedia(input); + input.focus(); +}); + function resetSubmittingForms() { document.querySelectorAll('form[data-submitting="true"]').forEach((form) => { delete form.dataset.submitting; @@ -590,6 +640,7 @@ document.addEventListener("submit", async (event) => { } form.reset(); form.querySelectorAll("textarea[data-character-limit]").forEach(updateComposerCount); + form.querySelectorAll("input[type=file][data-composer-media]").forEach(updateComposerMedia); } } catch (_err) { form.submit(); @@ -663,6 +714,8 @@ pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) }); let csrf = csrf.unwrap_or_default(); let input_id = parent.map_or_else(|| "post-text".to_owned(), |id| format!("reply-text-{id}")); + let media_id = parent.map_or_else(|| "post-media".to_owned(), |id| format!("reply-media-{id}")); + let nsfw_id = parent.map_or_else(|| "post-nsfw".to_owned(), |id| format!("reply-nsfw-{id}")); let placeholder = if parent.is_some() { "Write a reply..." } else { @@ -672,8 +725,12 @@ pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) r#"

{}

{} remaining
{} +
-
+ + +
+
"#, if parent.is_some() { "Reply" @@ -688,7 +745,12 @@ pub fn composer(csrf: Option<&str>, parent: Option, max_text_chars: usize) html_escape::encode_double_quoted_attribute(&input_id), max_text_chars, max_text_chars, - html_escape::encode_double_quoted_attribute(placeholder) + html_escape::encode_double_quoted_attribute(placeholder), + html_escape::encode_double_quoted_attribute(&media_id), + html_escape::encode_double_quoted_attribute(&media_id), + icon_svg("paperclip"), + html_escape::encode_double_quoted_attribute(&nsfw_id), + html_escape::encode_double_quoted_attribute(&nsfw_id), ) } @@ -1411,6 +1473,9 @@ fn icon_svg(icon: &str) -> &'static str { "trash" => { r#""# } + "paperclip" => { + r#""# + } _ => r#""#, } } @@ -1757,14 +1822,14 @@ input[type=text].password-visible{padding-right:.8rem}.password-control{display: .search-panel h1{margin-bottom:.75rem}.search-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center}.search-form input{min-width:0}.search-results{display:grid;gap:.65rem}.section-title{margin:.2rem 0 .65rem;font-size:1.05rem;color:var(--text)}.search-users{margin-bottom:0}.search-users .section-title{margin-top:0}.search-account{grid-template-columns:auto minmax(0,1fr)} input:focus,textarea:focus,select:focus,button:focus-visible,a:focus-visible{outline:3px solid var(--focus);outline-offset:2px}button,.primary{border:1px solid var(--brand);background:var(--brand);color:var(--brand-text);border-radius:7px;padding:.5rem .8rem;cursor:pointer;font-weight:700}button:hover,.primary:hover{background:var(--brand-hover);text-decoration:none} nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.42rem .65rem}.rail-nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.5rem .65rem}.rail-nav button:hover,.mobile-nav button:hover{background:var(--hover);color:var(--link-strong)} -.composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem}.composer-media-controls{display:flex;align-items:center;gap:.8rem;flex-wrap:wrap}.composer-nsfw{margin:0}.file-control{display:inline-flex;align-items:center;gap:.6rem;margin:0;color:var(--link-strong);font-weight:700}.file-control input{max-width:15rem} +.composer-surface{border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);overflow:hidden}.composer-surface textarea{border:0;border-radius:0;background:transparent;min-height:7rem;resize:vertical}.composer-surface textarea:focus{outline:0;box-shadow:inset 0 0 0 3px var(--focus)}.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:.55rem;border-top:1px solid var(--border);padding:.42rem .5rem;background:var(--surface-subtle)}.composer-file-control{position:relative;display:inline-flex;align-items:center;gap:.45rem;max-width:100%;margin:0;color:var(--link-strong);font-weight:800}.composer-file-input{max-width:100%;color:var(--muted-strong)}.composer-file-input::file-selector-button{border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.34rem .58rem;font-weight:800;cursor:pointer}.composer-file-input::file-selector-button:hover{background:var(--hover);color:var(--text-strong)}.composer-file-button{display:none}.js-enabled .composer-file-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.js-enabled .composer-file-button{display:inline-flex;align-items:center;gap:.38rem;min-height:2rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.3rem .55rem;cursor:pointer}.composer-file-button svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.js-enabled .composer-file-control:hover .composer-file-button{background:var(--hover);color:var(--text-strong)}.js-enabled .composer-file-input:focus-visible+.composer-file-button{outline:3px solid var(--focus);outline-offset:2px}.composer-media-selection[hidden]{display:none}.composer-media-selection{display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;border-top:1px solid var(--border);padding:.5rem;background:var(--surface)}.composer-media-summary{font-weight:800;color:var(--muted-strong);overflow-wrap:anywhere}.composer-nsfw{margin:0}.composer-clear-media{background:var(--surface);color:var(--link-strong);border-color:var(--border);padding:.32rem .55rem}.composer-clear-media:hover{background:var(--hover);color:var(--text-strong)}.composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem} .thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} .timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px}.nsfw-media{position:relative;margin-top:.5rem}.nsfw-media .post img,.nsfw-media .post video{margin-top:0}.nsfw-media-frame{position:relative;display:block;overflow:hidden;border-radius:8px}.nsfw-media-frame img,.nsfw-media-frame video{margin-top:0;filter:blur(24px);transform:scale(1.02)}.nsfw-toggle:checked+.nsfw-media-frame img,.nsfw-toggle:checked+.nsfw-media-frame video{filter:none;transform:none}.nsfw-badge{position:absolute;left:.55rem;bottom:.55rem;border-radius:999px;padding:.18rem .5rem;background:rgba(0,0,0,.72);color:#fff;font-size:.78rem;font-weight:900;letter-spacing:.03em}.nsfw-show{position:absolute;right:.55rem;bottom:.55rem;margin:0;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text-strong);padding:.32rem .65rem;font-weight:900;box-shadow:0 1px 2px var(--shadow);cursor:pointer}.nsfw-show:hover{background:var(--hover)}.nsfw-toggle:focus-visible~.nsfw-show{outline:3px solid var(--focus);outline-offset:2px}.nsfw-toggle:checked~.nsfw-show,.nsfw-toggle:checked+.nsfw-media-frame .nsfw-badge{display:none}.nsfw-open{display:block;margin:.35rem 0 0;font-size:.9rem;font-weight:700} .counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.post-permalink{font-weight:700;color:var(--link-strong)}.js-enabled .post-permalink{display:none}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.admin-nsfw-button{min-height:2.2rem;padding:.3rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border);font-size:.86rem}.admin-nsfw-button:hover{background:var(--hover);color:var(--text-strong)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem}.noscript-banner{max-width:1100px;margin:.7rem auto 0;padding:.65rem .85rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);color:var(--muted-strong)}.js-enabled .noscript-banner{display:none} .profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} @media (max-width:1100px){.app-shell{--shell-side:220px;--shell-max:880px;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary))}.right-rail{display:none}} @media (max-width:820px){.app-shell{grid-template-columns:minmax(0,680px)}.left-rail,.right-rail{display:none}.mobile-nav{display:flex}} -@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:center;width:100%;gap:.55rem}.tor-indicator{max-width:calc(100% - 7rem);margin-left:auto}.tor-details{left:auto;right:0;max-width:calc(100vw - 1.5rem)}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.file-control{display:block}.file-control input{display:block;max-width:100%;margin-top:.35rem}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} +@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:center;width:100%;gap:.55rem}.tor-indicator{max-width:calc(100% - 7rem);margin-left:auto}.tor-details{left:auto;right:0;max-width:calc(100vw - 1.5rem)}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.composer-footer,.composer-media-selection{align-items:flex-start;flex-direction:column}.composer-file-input{max-width:100%}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} "#; #[cfg(test)] @@ -2010,9 +2075,22 @@ mod tests { assert!(post.contains("maxlength=\"512\"")); assert!(post.contains("What is happening?")); assert!(post.contains(r#"placeholder="What's happening?""#)); + assert!(post.contains(r#"
"#)); + assert!(post.contains(r#"id="post-media" name="media" type="file""#)); + assert!(post.contains(r#"aria-label="Attach media" data-composer-media"#)); + assert!( + post.contains( + r#"class="composer-media-selection" data-composer-media-selection hidden"# + ) + ); + assert!(post.contains(r#"id="post-nsfw" name="nsfw" type="checkbox""#)); + assert!(post.contains(r#"data-composer-clear-media"#)); + assert!(!post.contains(r#"class="file-control""#)); let reply = composer(Some("csrf"), Some(10), 512); assert!(reply.contains(r#"id="reply-text-10""#)); + assert!(reply.contains(r#"id="reply-media-10" name="media" type="file""#)); + assert!(reply.contains(r#"id="reply-nsfw-10" name="nsfw" type="checkbox""#)); assert!(reply.contains(r#"placeholder="Write a reply...""#)); } diff --git a/src/server.rs b/src/server.rs index c155ab7..398698f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1356,6 +1356,49 @@ fn settings_query_notice(saved: Option<&str>) -> Option<(&'static str, &'static } } +struct SettingsProfile { + display_name: String, + bio: String, + location: String, + website: String, + theme: String, + nsfw_blur_enabled: bool, + picture_path: Option, + banner_path: Option, +} + +async fn settings_profile(pool: &SqlitePool, user_id: i64) -> AppResult { + pool.call(move |conn| { + conn.query_row( + r#" + SELECT u.display_name, u.bio, u.location, u.website, u.theme, u.nsfw_blur_enabled, + pic.public_path AS profile_picture_path, + banner.public_path AS banner_path + FROM users u + LEFT JOIN media pic ON pic.id = u.profile_picture_media_id + LEFT JOIN media banner ON banner.id = u.banner_media_id + WHERE u.id = ? + "#, + [user_id], + |row| { + Ok(SettingsProfile { + display_name: row.get(0)?, + bio: row.get(1)?, + location: row.get(2)?, + website: row.get(3)?, + theme: row.get(4)?, + nsfw_blur_enabled: row.get::<_, i64>(5)? != 0, + picture_path: row.get(6)?, + banner_path: row.get(7)?, + }) + }, + ) + .map_err(Into::into) + }) + .await + .map_err(Into::into) +} + fn settings_profile_media( picture_path: Option<&str>, banner_path: Option<&str>, @@ -1531,48 +1574,20 @@ async fn settings_page( csrf: &str, notice: Option<(&str, &str)>, ) -> AppResult { - let user_id = user.id; - let profile = state - .pool - .call(move |conn| { - conn.query_row( - r#" - SELECT u.display_name, u.bio, u.location, u.website, u.theme, u.nsfw_blur_enabled, - pic.public_path AS profile_picture_path, - banner.public_path AS banner_path - FROM users u - LEFT JOIN media pic ON pic.id = u.profile_picture_media_id - LEFT JOIN media banner ON banner.id = u.banner_media_id - WHERE u.id = ? - "#, - [user_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, i64>(5)? != 0, - row.get::<_, Option>(6)?, - row.get::<_, Option>(7)?, - )) - }, - ) - .map_err(Into::into) - }) - .await?; - let (display_name, bio, location, website, theme, nsfw_blur_enabled, picture_path, banner_path) = - profile; + let profile = settings_profile(&state.pool, user.id).await?; let blocked = social::blocked_users(&state.pool, user.id).await?; let muted = social::muted_users(&state.pool, user.id).await?; let muted_words = social::muted_words(&state.pool, user.id).await?; - let dark_checked = if Theme::from(theme.as_str()) == Theme::Dark { + let dark_checked = if Theme::from(profile.theme.as_str()) == Theme::Dark { + " checked" + } else { + "" + }; + let nsfw_checked = if profile.nsfw_blur_enabled { " checked" } else { "" }; - let nsfw_checked = if nsfw_blur_enabled { " checked" } else { "" }; let notice_html = notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); let password_hint = if state.settings.accounts.min_password_length == 0 { @@ -1592,8 +1607,8 @@ async fn settings_page( "confirm-new-password-requirement", ); let profile_media = settings_profile_media( - picture_path.as_deref(), - banner_path.as_deref(), + profile.picture_path.as_deref(), + profile.banner_path.as_deref(), state.settings.accounts.allow_profile_pictures, state.settings.accounts.allow_profile_banners, ); @@ -1603,10 +1618,10 @@ async fn settings_page( profile_media, dark_checked, nsfw_checked, - html_escape::encode_double_quoted_attribute(display_name.as_str()), - html_escape::encode_text(bio.as_str()), - html_escape::encode_double_quoted_attribute(location.as_str()), - html_escape::encode_double_quoted_attribute(website.as_str()), + html_escape::encode_double_quoted_attribute(profile.display_name.as_str()), + html_escape::encode_text(profile.bio.as_str()), + html_escape::encode_double_quoted_attribute(profile.location.as_str()), + html_escape::encode_double_quoted_attribute(profile.website.as_str()), settings_user_list( &blocked, "/unblock", From 22b10b9fb63a4163c7a5b3a485f488d9e13f9d8f Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 23 May 2026 18:32:32 -0700 Subject: [PATCH 20/23] Add duplicate media canonical reuse and cleanup --- src/account.rs | 147 ++++- src/db.rs | 105 +++- src/media.rs | 1442 ++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 1568 insertions(+), 126 deletions(-) diff --git a/src/account.rs b/src/account.rs index 88b2f19..5b45085 100644 --- a/src/account.rs +++ b/src/account.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::fs; use std::path::{Component, Path, PathBuf}; -use rusqlite::params; +use rusqlite::{OptionalExtension as _, params}; use crate::auth; use crate::db::SqlitePool; @@ -70,6 +70,7 @@ pub async fn delete_account( #[derive(Debug, Clone)] struct AccountMedia { id: i64, + original_path: Option, stored_path: String, thumbnail_path: Option, } @@ -80,7 +81,7 @@ fn account_media_in_tx( ) -> anyhow::Result> { let mut stmt = tx.prepare( r#" - SELECT DISTINCT m.id, m.stored_path, m.thumbnail_path + SELECT DISTINCT m.id, m.original_path, m.stored_path, m.thumbnail_path FROM media m WHERE m.owner_user_id = ? OR m.id IN ( @@ -95,8 +96,9 @@ fn account_media_in_tx( .query_map(params![user_id, user_id], |row| { Ok(AccountMedia { id: row.get(0)?, - stored_path: row.get(1)?, - thumbnail_path: row.get(2)?, + original_path: row.get(1)?, + stored_path: row.get(2)?, + thumbnail_path: row.get(3)?, }) })? .collect::, _>>()?; @@ -117,11 +119,20 @@ async fn scrub_account_rows( [user_id], )?; let media = account_media_in_tx(&tx, user_id)?; - let mut media_files = BTreeSet::new(); + let mut media_paths = Vec::new(); for item in &media { - media_files.extend(safe_media_path(&paths, &item.stored_path)?); + if let Some(original_path) = &item.original_path { + media_paths.push(original_path.clone()); + } + media_paths.push(item.stored_path.clone()); if let Some(thumbnail_path) = &item.thumbnail_path { - media_files.extend(safe_media_path(&paths, thumbnail_path)?); + media_paths.push(thumbnail_path.clone()); + } + } + let mut safe_paths = Vec::new(); + for path in &media_paths { + if let Some(safe_path) = safe_media_path(&paths, path)? { + safe_paths.push((path.clone(), safe_path)); } } let media_ids = media.into_iter().map(|item| item.id).collect::>(); @@ -165,9 +176,21 @@ async fn scrub_account_rows( )?; tx.execute("DELETE FROM posts WHERE user_id = ?", [user_id])?; for media_id in media_ids { + promote_canonical_references(&tx, media_id)?; tx.execute("DELETE FROM media_jobs WHERE media_id = ?", [media_id])?; tx.execute("DELETE FROM media WHERE id = ?", [media_id])?; } + let mut media_files = BTreeSet::new(); + for (raw_path, safe_path) in safe_paths { + let remaining: i64 = tx.query_row( + "SELECT COUNT(*) FROM media WHERE original_path = ? OR stored_path = ? OR thumbnail_path = ?", + params![raw_path, raw_path, raw_path], + |row| row.get(0), + )?; + if remaining == 0 { + media_files.insert(safe_path); + } + } tx.execute("DELETE FROM users WHERE id = ?", [user_id])?; tx.commit()?; Ok(media_files) @@ -229,6 +252,35 @@ fn has_parent_component(path: &Path) -> bool { .any(|component| matches!(component, Component::ParentDir)) } +fn promote_canonical_references( + tx: &rusqlite::Transaction<'_>, + media_id: i64, +) -> anyhow::Result<()> { + let replacement: Option = tx + .query_row( + "SELECT id FROM media WHERE canonical_media_id = ? ORDER BY id ASC LIMIT 1", + [media_id], + |row| row.get(0), + ) + .optional()?; + let Some(replacement) = replacement else { + return Ok(()); + }; + tx.execute( + "UPDATE media SET original_sha256 = '', normalized_sha256 = NULL WHERE id = ?", + [media_id], + )?; + tx.execute( + "UPDATE media SET canonical_media_id = NULL WHERE id = ?", + [replacement], + )?; + tx.execute( + "UPDATE media SET canonical_media_id = ? WHERE canonical_media_id = ?", + params![replacement, media_id], + )?; + Ok(()) +} + fn remove_media_files(paths: &BTreeSet, user_id: i64) -> usize { let mut deleted = 0; for path in paths { @@ -252,7 +304,6 @@ fn remove_media_files(paths: &BTreeSet, user_id: i64) -> usize { mod tests { use super::*; use crate::{auth, config::Settings, db, social}; - use rusqlite::OptionalExtension as _; async fn fixture() -> ( tempfile::TempDir, @@ -413,6 +464,86 @@ mod tests { assert_eq!(rows, 0); } + #[tokio::test] + async fn delete_account_keeps_shared_media_files_referenced_by_other_rows() { + let (_temp, paths, pool, _settings, alice, bob) = fixture().await; + let media_path = paths.uploads_images.join("shared.webp"); + fs::write(&media_path, b"shared").expect("shared media file"); + let media_path_string = media_path.to_string_lossy().to_string(); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'alice.webp', ?, '/uploads/images/shared.webp', 'image/webp', 'image', 6)", + params![alice, media_path_string], + )?; + let media_path_string = media_path_string.clone(); + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'bob.webp', ?, '/uploads/images/shared.webp', 'image/webp', 'image', 6)", + params![bob, media_path_string], + )?; + Ok(()) + }) + .await + .expect("shared media rows"); + + let summary = delete_account(&pool, &paths, alice, "very secure password") + .await + .expect("delete account"); + + assert_eq!(summary.deleted_media_files, 0); + assert!(media_path.exists()); + let bob_rows: i64 = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM media WHERE owner_user_id = ?", + [bob], + |row| row.get(0), + )?) + }) + .await + .expect("bob media count"); + assert_eq!(bob_rows, 1); + } + + #[tokio::test] + async fn delete_account_promotes_shared_canonical_media_for_remaining_rows() { + let (_temp, paths, pool, _settings, alice, bob) = fixture().await; + let media_path = paths.uploads_images.join("promoted.webp"); + fs::write(&media_path, b"shared").expect("shared media file"); + let media_path_string = media_path.to_string_lossy().to_string(); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (id, owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256) VALUES (100, ?, 'alice.webp', ?, '/uploads/images/promoted.webp', 'image/webp', 'image', 6, 'alice-raw', 'shared-normalized')", + params![alice, media_path_string], + )?; + let media_path_string = media_path_string.clone(); + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256, canonical_media_id) VALUES (?, 'bob.webp', ?, '/uploads/images/promoted.webp', 'image/webp', 'image', 6, 'bob-raw', 'shared-normalized', 100)", + params![bob, media_path_string], + )?; + Ok(()) + }) + .await + .expect("shared media rows"); + + let summary = delete_account(&pool, &paths, alice, "very secure password") + .await + .expect("delete account"); + + assert_eq!(summary.deleted_media_files, 0); + assert!(media_path.exists()); + let bob_canonical: Option = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT canonical_media_id FROM media WHERE owner_user_id = ?", + [bob], + |row| row.get(0), + )?) + }) + .await + .expect("bob canonical"); + assert_eq!(bob_canonical, None); + } + #[tokio::test] async fn delete_account_treats_file_cleanup_failure_as_success_after_db_scrub() { let (_temp, paths, pool, _settings, alice, _bob) = fixture().await; diff --git a/src/db.rs b/src/db.rs index a61b306..bceec93 100644 --- a/src/db.rs +++ b/src/db.rs @@ -124,6 +124,10 @@ pub async fn migrate(pool: &Db) -> anyhow::Result<()> { tx.execute_batch(MIGRATION_10)?; tx.execute("INSERT INTO schema_migrations (version) VALUES (10)", [])?; } + if applied.unwrap_or(0) < 11 { + tx.execute_batch(MIGRATION_11)?; + tx.execute("INSERT INTO schema_migrations (version) VALUES (11)", [])?; + } tx.commit()?; Ok(()) }) @@ -363,6 +367,69 @@ ALTER TABLE users ADD COLUMN nsfw_blur_enabled INTEGER NOT NULL DEFAULT 1; CREATE INDEX IF NOT EXISTS idx_media_is_nsfw ON media(is_nsfw); "#; +const MIGRATION_11: &str = r#" +ALTER TABLE media ADD COLUMN original_path TEXT; +ALTER TABLE media ADD COLUMN original_public_path TEXT; +ALTER TABLE media ADD COLUMN original_sha256 TEXT NOT NULL DEFAULT ''; +ALTER TABLE media ADD COLUMN normalized_sha256 TEXT; +ALTER TABLE media ADD COLUMN canonical_media_id INTEGER REFERENCES media(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_media_original_sha256 + ON media(media_kind, original_sha256) + WHERE original_sha256 != ''; +CREATE INDEX IF NOT EXISTS idx_media_normalized_sha256 + ON media(media_kind, normalized_sha256) + WHERE normalized_sha256 IS NOT NULL AND normalized_sha256 != ''; +CREATE INDEX IF NOT EXISTS idx_media_canonical_media_id ON media(canonical_media_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_media_unique_canonical_original_sha256 + ON media(media_kind, original_sha256) + WHERE canonical_media_id IS NULL AND original_sha256 != ''; +CREATE UNIQUE INDEX IF NOT EXISTS idx_media_unique_canonical_normalized_sha256 + ON media(media_kind, normalized_sha256) + WHERE canonical_media_id IS NULL AND normalized_sha256 IS NOT NULL AND normalized_sha256 != ''; + +CREATE TRIGGER IF NOT EXISTS media_canonical_ref_insert +BEFORE INSERT ON media +WHEN NEW.canonical_media_id IS NOT NULL +BEGIN + SELECT CASE + WHEN NEW.id = NEW.canonical_media_id + THEN RAISE(ABORT, 'media cannot reference itself as canonical') + END; + SELECT CASE + WHEN NOT EXISTS ( + SELECT 1 + FROM media canonical + WHERE canonical.id = NEW.canonical_media_id + AND canonical.canonical_media_id IS NULL + AND canonical.media_kind = NEW.media_kind + ) + THEN RAISE(ABORT, 'media canonical reference must point to same-kind canonical media') + END; +END; + +CREATE TRIGGER IF NOT EXISTS media_canonical_ref_update +BEFORE UPDATE OF canonical_media_id, media_kind ON media +WHEN NEW.canonical_media_id IS NOT NULL +BEGIN + SELECT CASE + WHEN NEW.id = NEW.canonical_media_id + THEN RAISE(ABORT, 'media cannot reference itself as canonical') + END; + SELECT CASE + WHEN NOT EXISTS ( + SELECT 1 + FROM media canonical + WHERE canonical.id = NEW.canonical_media_id + AND canonical.canonical_media_id IS NULL + AND canonical.media_kind = NEW.media_kind + ) + THEN RAISE(ABORT, 'media canonical reference must point to same-kind canonical media') + END; +END; +"#; + #[cfg(test)] mod tests { use super::*; @@ -404,12 +471,48 @@ mod tests { .await .expect("settings"); - assert_eq!(versions, 10); + assert_eq!(versions, 11); assert_eq!(foreign_keys, 1); assert_eq!(journal_mode, "wal"); assert!(busy_timeout >= 5_000); } + #[tokio::test] + async fn canonical_media_constraints_reject_unsafe_references() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("migrate"); + + pool.call(|conn| { + conn.execute( + "INSERT INTO media (id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256) VALUES (10, 'image.webp', '/tmp/image.webp', '/uploads/images/image.webp', 'image/webp', 'image', 1, 'raw', 'norm')", + [], + )?; + let duplicate_canonical = conn.execute( + "INSERT INTO media (original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256) VALUES ('copy.webp', '/tmp/copy.webp', '/uploads/images/copy.webp', 'image/webp', 'image', 1, 'raw')", + [], + ); + assert!(duplicate_canonical.is_err()); + + let cross_kind = conn.execute( + "INSERT INTO media (original_filename, stored_path, public_path, mime_type, media_kind, byte_len, canonical_media_id) VALUES ('clip.webm', '/tmp/clip.webm', '/uploads/videos/clip.webm', 'video/webm', 'video', 1, 10)", + [], + ); + assert!(cross_kind.is_err()); + + let self_reference = conn.execute( + "INSERT INTO media (id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, canonical_media_id) VALUES (11, 'self.webp', '/tmp/self.webp', '/uploads/images/self.webp', 'image/webp', 'image', 1, 11)", + [], + ); + assert!(self_reference.is_err()); + Ok(()) + }) + .await + .expect("constraint checks"); + } + #[tokio::test] async fn users_get_light_theme_by_default() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/src/media.rs b/src/media.rs index 4dda511..7e00622 100644 --- a/src/media.rs +++ b/src/media.rs @@ -1,7 +1,9 @@ -use std::path::{Path, PathBuf}; +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; use axum::extract::multipart::Field; use rusqlite::{OptionalExtension as _, params}; +use sha2::{Digest as _, Sha256}; use tokio::io::AsyncWriteExt as _; use uuid::Uuid; @@ -11,6 +13,7 @@ use crate::ffmpeg::FfmpegStatus; use crate::runtime::RuntimePaths; const PROFILE_PICTURE_THUMB_SIZE: u16 = 96; +const HASH_PREFIX_LEN: usize = 16; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MediaKind { @@ -24,6 +27,78 @@ pub enum ProfileMediaSlot { Banner, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MediaHashKind { + Original, + Normalized, +} + +#[derive(Debug, Clone)] +struct StoredMedia { + id: i64, + stored_path: String, + public_path: String, + mime_type: String, + media_kind: String, + original_sha256: String, + normalized_sha256: Option, +} + +#[derive(Debug, Clone)] +struct StoredVariant { + path: PathBuf, + public_path: String, + mime_type: String, + state: String, + stderr: String, +} + +#[derive(Debug, Clone)] +struct OriginalVariant { + path: PathBuf, + public_path: String, +} + +struct UploadContext<'a> { + pool: &'a SqlitePool, + settings: &'a Settings, + paths: &'a RuntimePaths, + ffmpeg: &'a FfmpegStatus, +} + +struct StagedUpload { + owner_user_id: Option, + original_filename: String, + staging: PathBuf, + bytes: u64, +} + +struct PreparedUpload { + owner_user_id: Option, + original_filename: String, + staging: PathBuf, + bytes: u64, + mime: String, + media_kind: MediaKind, + original_sha256: String, +} + +struct NewMediaRecord { + owner_user_id: Option, + original_filename: String, + original_path: Option, + original_public_path: Option, + stored_path: String, + public_path: String, + mime_type: String, + media_kind: String, + byte_len: i64, + conversion_state: String, + ffmpeg_stderr: String, + original_sha256: String, + normalized_sha256: String, +} + impl ProfileMediaSlot { fn column(self) -> &'static str { match self { @@ -55,92 +130,299 @@ pub async fn save_upload( let id = Uuid::new_v4().simple().to_string(); let staging = paths.staged_upload_path(&id); let bytes = write_upload_to_staging(settings, &staging, &mut field).await?; - let data = tokio::fs::read(&staging).await?; + let context = UploadContext { + pool, + settings, + paths, + ffmpeg, + }; + save_staged_upload( + &context, + StagedUpload { + owner_user_id, + original_filename, + staging, + bytes, + }, + ) + .await +} + +async fn save_staged_upload( + context: &UploadContext<'_>, + upload: StagedUpload, +) -> anyhow::Result { + let prepared = prepare_staged_upload(context.settings, upload).await?; + if let Some(media_id) = try_insert_duplicate( + context, + &prepared, + MediaHashKind::Original, + &prepared.original_sha256, + ) + .await? + { + remove_staged_upload(&prepared.staging).await; + return Ok(media_id); + } + store_new_upload(context, prepared).await +} + +async fn prepare_staged_upload( + settings: &Settings, + upload: StagedUpload, +) -> anyhow::Result { + let data = tokio::fs::read(&upload.staging).await?; let Some(kind) = infer::get(&data) else { - remove_staged_upload(&staging).await; + remove_staged_upload(&upload.staging).await; anyhow::bail!("unsupported media type"); }; let mime = kind.mime_type().to_owned(); - let media_kind = match classify(settings, &mime, bytes) { + let media_kind = match classify(settings, &mime, upload.bytes) { Ok(media_kind) => media_kind, Err(error) => { - remove_staged_upload(&staging).await; + remove_staged_upload(&upload.staging).await; return Err(error); } }; - let ext = safe_extension(&mime, media_kind); - let original_path = paths.uploads_originals.join(format!("{id}.{ext}")); - tokio::fs::rename(&staging, &original_path).await?; - let (stored_path, public_path, served_mime, state, stderr) = convert_or_original( - settings, - paths, - ffmpeg, - &original_path, - &id, + Ok(PreparedUpload { + owner_user_id: upload.owner_user_id, + original_filename: upload.original_filename, + staging: upload.staging, + bytes: upload.bytes, + mime, media_kind, - &mime, + original_sha256: sha256_hex(&data), + }) +} + +async fn try_insert_duplicate( + context: &UploadContext<'_>, + upload: &PreparedUpload, + hash_kind: MediaHashKind, + hash: &str, +) -> anyhow::Result> { + if let Some(canonical) = find_duplicate_media( + context.pool, + context.paths, + upload.media_kind, + hash_kind, + hash, + ) + .await? + { + tracing::info!( + canonical_media_id = canonical.id, + media_kind = upload.media_kind.as_str(), + hash_kind = ?hash_kind, + "reusing media by content hash" + ); + return insert_duplicate_media( + context.pool, + upload.owner_user_id, + upload.original_filename.clone(), + upload.bytes, + upload.original_sha256.clone(), + canonical, + ) + .await + .map(Some); + } + Ok(None) +} + +async fn store_new_upload( + context: &UploadContext<'_>, + upload: PreparedUpload, +) -> anyhow::Result { + let ext = safe_extension(&upload.mime, upload.media_kind); + let mut basename = stable_media_basename(&upload.original_filename, &upload.original_sha256); + let original_path = unique_media_path(&context.paths.uploads_originals, &basename, ext); + if let Some(stem) = original_path.file_stem().and_then(|stem| stem.to_str()) { + basename = stem.to_owned(); + } + let original_public_path = public_upload_path(context.paths, &original_path)?; + tokio::fs::rename(&upload.staging, &original_path).await?; + let stored = convert_or_original( + context.settings, + context.paths, + context.ffmpeg, + &original_path, + &basename, + upload.media_kind, + &upload.mime, ) .await; - if state == "converted" && !settings.media.keep_original_uploads { + let normalized_sha256 = hash_file(&stored.path).await?; + if stored.state == "converted" && !context.settings.media.keep_original_uploads { let _ = tokio::fs::remove_file(&original_path).await; } - let cleanup_paths = [original_path.clone(), stored_path.clone()]; - let stored_path = stored_path.to_string_lossy().to_string(); - let media_kind = media_kind.as_str().to_owned(); - let byte_len = i64::try_from(bytes)?; - let db_state = state.clone(); - let db_stderr = stderr.clone(); - let media_insert = pool - .call(move |conn| { - conn.execute( - "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, conversion_state, ffmpeg_stderr) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - params![ - owner_user_id, - original_filename, - stored_path, - public_path, - served_mime, - media_kind, - byte_len, - db_state, - db_stderr - ], - )?; - Ok(conn.last_insert_rowid()) - }) - .await; - let media_id = match media_insert { + + if let Some(media_id) = try_insert_duplicate( + context, + &upload, + MediaHashKind::Normalized, + &normalized_sha256, + ) + .await? + { + cleanup_unreferenced_uploads([&original_path, &stored.path]).await; + return Ok(media_id); + } + + let original_variant = + if stored.state == "converted" && !context.settings.media.keep_original_uploads { + None + } else { + Some(OriginalVariant { + path: original_path.clone(), + public_path: original_public_path, + }) + }; + let cleanup_paths = [original_path.clone(), stored.path.clone()]; + let original_path = original_variant + .as_ref() + .map(|variant| variant.path.to_string_lossy().to_string()); + let record = NewMediaRecord { + owner_user_id: upload.owner_user_id, + original_filename: upload.original_filename.clone(), + original_path, + original_public_path: original_variant.map(|variant| variant.public_path), + stored_path: stored.path.to_string_lossy().to_string(), + public_path: stored.public_path, + mime_type: stored.mime_type, + media_kind: upload.media_kind.as_str().to_owned(), + byte_len: i64::try_from(upload.bytes)?, + conversion_state: stored.state, + ffmpeg_stderr: stored.stderr, + original_sha256: upload.original_sha256.clone(), + normalized_sha256: normalized_sha256.clone(), + }; + let media_id = match insert_new_media_record(context.pool, record).await { Ok(media_id) => media_id, Err(error) => { - for path in cleanup_paths { - if let Err(remove_error) = tokio::fs::remove_file(&path).await - && remove_error.kind() != std::io::ErrorKind::NotFound - { - tracing::warn!( - path = %path.display(), - error = %remove_error, - "failed to remove uploaded file after media database insert failed" - ); - } + if let Some(media_id) = + try_insert_duplicate_after_canonical_conflict(context, &upload, &normalized_sha256) + .await? + { + cleanup_unreferenced_uploads([&cleanup_paths[0], &cleanup_paths[1]]).await; + return Ok(media_id); } + cleanup_unreferenced_uploads([&cleanup_paths[0], &cleanup_paths[1]]).await; return Err(error); } }; - if state == "converted" || state == "fallback" { - let status = state; - let stderr_summary = stderr_summary_for_db(&stderr); - pool.call(move |conn| { - conn.execute( - "INSERT INTO media_jobs (media_id, status, stderr_summary, finished_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)", - params![media_id, status, stderr_summary], - )?; - Ok(()) - }) - .await?; - } Ok(media_id) } +async fn insert_new_media_record(pool: &SqlitePool, record: NewMediaRecord) -> anyhow::Result { + pool.call(move |conn| { + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO media (owner_user_id, original_filename, original_path, original_public_path, stored_path, public_path, mime_type, media_kind, byte_len, conversion_state, ffmpeg_stderr, original_sha256, normalized_sha256) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + record.owner_user_id, + record.original_filename, + record.original_path, + record.original_public_path, + record.stored_path, + record.public_path, + record.mime_type, + record.media_kind, + record.byte_len, + record.conversion_state, + record.ffmpeg_stderr, + record.original_sha256, + record.normalized_sha256, + ], + )?; + let media_id = tx.last_insert_rowid(); + record_media_job_in_tx( + &tx, + media_id, + &record.conversion_state, + &record.ffmpeg_stderr, + )?; + tx.commit()?; + Ok(media_id) + }) + .await +} + +fn record_media_job_in_tx( + tx: &rusqlite::Transaction<'_>, + media_id: i64, + state: &str, + stderr: &str, +) -> anyhow::Result<()> { + if state != "converted" && state != "fallback" { + return Ok(()); + } + let stderr_summary = stderr_summary_for_db(stderr); + tx.execute( + "INSERT INTO media_jobs (media_id, status, stderr_summary, finished_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)", + params![media_id, state, stderr_summary], + )?; + Ok(()) +} + +async fn try_insert_duplicate_after_canonical_conflict( + context: &UploadContext<'_>, + upload: &PreparedUpload, + normalized_sha256: &str, +) -> anyhow::Result> { + if let Some(media_id) = try_insert_duplicate( + context, + upload, + MediaHashKind::Original, + &upload.original_sha256, + ) + .await? + { + return Ok(Some(media_id)); + } + try_insert_duplicate( + context, + upload, + MediaHashKind::Normalized, + normalized_sha256, + ) + .await +} + +async fn insert_duplicate_media( + pool: &SqlitePool, + owner_user_id: Option, + original_filename: String, + bytes: u64, + original_sha256: String, + canonical: StoredMedia, +) -> anyhow::Result { + let byte_len = i64::try_from(bytes)?; + let normalized_sha256 = canonical + .normalized_sha256 + .clone() + .filter(|hash| !hash.is_empty()); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, conversion_state, original_sha256, normalized_sha256, canonical_media_id) VALUES (?, ?, ?, ?, ?, ?, ?, 'duplicate', ?, ?, ?)", + params![ + owner_user_id, + original_filename, + canonical.stored_path, + canonical.public_path, + canonical.mime_type, + canonical.media_kind, + byte_len, + original_sha256, + normalized_sha256, + canonical.id, + ], + )?; + Ok(conn.last_insert_rowid()) + }) + .await +} + async fn write_upload_to_staging( settings: &Settings, staging: &Path, @@ -245,6 +527,294 @@ async fn remove_staged_upload(path: &Path) { } } +async fn cleanup_unreferenced_uploads(paths: [&Path; N]) { + let unique_paths = paths.into_iter().collect::>(); + for path in unique_paths { + if let Err(error) = tokio::fs::remove_file(path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + path = %path.display(), + error = %error, + "failed to remove duplicate upload scratch file" + ); + } + } +} + +async fn find_duplicate_media( + pool: &SqlitePool, + paths: &RuntimePaths, + media_kind: MediaKind, + hash_kind: MediaHashKind, + hash: &str, +) -> anyhow::Result> { + let media_kind_str = media_kind.as_str().to_owned(); + let hash = hash.to_owned(); + let query_hash = hash.clone(); + let candidates = pool + .call(move |conn| { + let sql = match hash_kind { + MediaHashKind::Original => { + "SELECT id, stored_path, public_path, mime_type, media_kind, original_sha256, normalized_sha256 FROM media WHERE canonical_media_id IS NULL AND media_kind = ? AND original_sha256 = ? ORDER BY id ASC LIMIT 8" + } + MediaHashKind::Normalized => { + "SELECT id, stored_path, public_path, mime_type, media_kind, original_sha256, normalized_sha256 FROM media WHERE canonical_media_id IS NULL AND media_kind = ? AND normalized_sha256 = ? ORDER BY id ASC LIMIT 8" + } + }; + let mut stmt = conn.prepare(sql)?; + let rows = stmt + .query_map(params![media_kind_str, query_hash], |row| { + Ok(StoredMedia { + id: row.get(0)?, + stored_path: row.get(1)?, + public_path: row.get(2)?, + mime_type: row.get(3)?, + media_kind: row.get(4)?, + original_sha256: row.get(5)?, + normalized_sha256: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await?; + + for candidate in candidates { + if validate_canonical_media(paths, &candidate, media_kind, hash_kind, hash.as_str()).await { + return Ok(Some(candidate)); + } + tracing::warn!( + media_id = candidate.id, + media_kind = media_kind.as_str(), + hash_kind = ?hash_kind, + "ignoring invalid duplicate media candidate" + ); + quarantine_invalid_canonical_media(pool, candidate.id).await?; + } + Ok(None) +} + +async fn quarantine_invalid_canonical_media( + pool: &SqlitePool, + media_id: i64, +) -> anyhow::Result<()> { + pool.call(move |conn| { + conn.execute( + "UPDATE media SET original_sha256 = '', normalized_sha256 = NULL WHERE id = ? AND canonical_media_id IS NULL", + [media_id], + )?; + Ok(()) + }) + .await +} + +async fn validate_canonical_media( + paths: &RuntimePaths, + candidate: &StoredMedia, + expected_kind: MediaKind, + hash_kind: MediaHashKind, + expected_hash: &str, +) -> bool { + if candidate.media_kind != expected_kind.as_str() + || !mime_matches_kind(&candidate.mime_type, expected_kind) + { + return false; + } + let path = PathBuf::from(&candidate.stored_path); + if !safe_stored_media_path(paths, &path, expected_kind) { + return false; + } + let Ok(public_path) = public_upload_path(paths, &path) else { + return false; + }; + if public_path != candidate.public_path { + return false; + } + if !stored_file_is_safe(paths, &path, expected_kind, &candidate.mime_type).await { + return false; + } + let hash_to_check = match hash_kind { + MediaHashKind::Original => candidate + .normalized_sha256 + .as_deref() + .filter(|hash| !hash.is_empty()) + .or_else(|| { + (!candidate.original_sha256.is_empty()) + .then_some(candidate.original_sha256.as_str()) + }), + MediaHashKind::Normalized => Some(expected_hash), + }; + let Some(hash_to_check) = hash_to_check else { + return false; + }; + match hash_file(&path).await { + Ok(actual_hash) => actual_hash == hash_to_check, + Err(_) => false, + } +} + +fn safe_stored_media_path(paths: &RuntimePaths, path: &Path, media_kind: MediaKind) -> bool { + if !path.is_absolute() || has_parent_component(path) { + return false; + } + let allowed_roots = match media_kind { + MediaKind::Image => [&paths.uploads_images, &paths.uploads_originals], + MediaKind::Video => [&paths.uploads_videos, &paths.uploads_originals], + }; + allowed_roots.iter().any(|root| path.starts_with(root)) +} + +async fn stored_file_is_safe( + paths: &RuntimePaths, + path: &Path, + media_kind: MediaKind, + mime_type: &str, +) -> bool { + if !path_extension_matches_mime(path, media_kind, mime_type) { + return false; + } + let Ok(metadata) = tokio::fs::symlink_metadata(path).await else { + return false; + }; + if !metadata.is_file() { + return false; + } + let Ok(canonical_path) = tokio::fs::canonicalize(path).await else { + return false; + }; + let allowed_roots = match media_kind { + MediaKind::Image => [&paths.uploads_images, &paths.uploads_originals], + MediaKind::Video => [&paths.uploads_videos, &paths.uploads_originals], + }; + for root in allowed_roots { + let Ok(canonical_root) = tokio::fs::canonicalize(root).await else { + return false; + }; + if canonical_path.starts_with(canonical_root) { + return true; + } + } + false +} + +fn path_extension_matches_mime(path: &Path, media_kind: MediaKind, mime_type: &str) -> bool { + let Some(extension) = path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + else { + return false; + }; + match (media_kind, mime_type) { + (MediaKind::Image, "image/jpeg") => extension == "jpg" || extension == "jpeg", + (MediaKind::Image, "image/png") => extension == "png", + (MediaKind::Image, "image/gif") => extension == "gif", + (MediaKind::Image, "image/webp") => extension == "webp", + (MediaKind::Video, "video/webm") => extension == "webm", + (MediaKind::Video, "video/quicktime") => extension == "mov", + (MediaKind::Video, _) => extension == "mp4", + (MediaKind::Image, _) => extension == "img", + } +} + +fn mime_matches_kind(mime_type: &str, media_kind: MediaKind) -> bool { + match media_kind { + MediaKind::Image => mime_type.starts_with("image/"), + MediaKind::Video => mime_type.starts_with("video/"), + } +} + +async fn hash_file(path: &Path) -> anyhow::Result { + let data = tokio::fs::read(path).await?; + Ok(sha256_hex(&data)) +} + +fn sha256_hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + format!("{:x}", hasher.finalize()) +} + +fn stable_media_basename(original_filename: &str, original_sha256: &str) -> String { + let stem = Path::new(original_filename) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("upload"); + let mut sanitized = String::with_capacity(stem.len().min(64) + 1 + HASH_PREFIX_LEN); + let mut last_was_separator = false; + for character in stem.chars().flat_map(char::to_lowercase) { + let next = if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + Some(character) + } else if character.is_ascii_whitespace() { + Some('-') + } else { + None + }; + let Some(next) = next else { + continue; + }; + if matches!(next, '-' | '_' | '.') { + if sanitized.is_empty() || last_was_separator { + continue; + } + last_was_separator = true; + } else { + last_was_separator = false; + } + sanitized.push(next); + if sanitized.len() >= 64 { + break; + } + } + while sanitized.ends_with(['-', '_', '.']) { + sanitized.pop(); + } + if sanitized.is_empty() { + sanitized.push_str("upload"); + } + let prefix_len = original_sha256.len().min(HASH_PREFIX_LEN); + format!("{}-{}", sanitized, &original_sha256[..prefix_len]) +} + +fn unique_media_path(dir: &Path, basename: &str, extension: &str) -> PathBuf { + let path = dir.join(format!("{basename}.{extension}")); + if !path.exists() { + return path; + } + dir.join(format!( + "{}-{}.{}", + basename, + Uuid::new_v4().simple(), + extension + )) +} + +fn public_upload_path(paths: &RuntimePaths, path: &Path) -> anyhow::Result { + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow::anyhow!("media path has no file name"))?; + if path.starts_with(&paths.uploads_originals) { + return Ok(format!("/uploads/originals/{filename}")); + } + if path.starts_with(&paths.uploads_images) { + return Ok(format!("/uploads/images/{filename}")); + } + if path.starts_with(&paths.uploads_videos) { + return Ok(format!("/uploads/videos/{filename}")); + } + if path.starts_with(&paths.uploads_thumbs) { + return Ok(format!("/uploads/thumbs/{filename}")); + } + anyhow::bail!("media path is outside upload directories"); +} + +fn has_parent_component(path: &Path) -> bool { + path.components() + .any(|component| matches!(component, Component::ParentDir)) +} + pub async fn set_profile_media( pool: &SqlitePool, user_id: i64, @@ -310,33 +880,85 @@ pub async fn clear_profile_media( } pub async fn delete_media(pool: &SqlitePool, media_id: i64) -> anyhow::Result<()> { - let media_paths: Option<(String, Option)> = pool + let paths_to_remove = pool .call(move |conn| { - conn.query_row( - "SELECT stored_path, thumbnail_path FROM media WHERE id = ?", - [media_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .map_err(Into::into) + let tx = conn.transaction()?; + let media_paths: Option<(Option, String, Option)> = tx + .query_row( + "SELECT original_path, stored_path, thumbnail_path FROM media WHERE id = ?", + [media_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let Some((original_path, stored_path, thumbnail_path)) = media_paths else { + return Ok(Vec::new()); + }; + promote_canonical_references(&tx, media_id)?; + tx.execute("DELETE FROM media_jobs WHERE media_id = ?", [media_id])?; + tx.execute("DELETE FROM media WHERE id = ?", [media_id])?; + let mut paths = BTreeSet::new(); + for path in [original_path, Some(stored_path), thumbnail_path] + .into_iter() + .flatten() + .filter(|path| !path.is_empty()) + { + let remaining: i64 = tx.query_row( + "SELECT COUNT(*) FROM media WHERE original_path = ? OR stored_path = ? OR thumbnail_path = ?", + params![path, path, path], + |row| row.get(0), + )?; + if remaining == 0 { + paths.insert(PathBuf::from(path)); + } + } + tx.commit()?; + Ok(paths.into_iter().collect::>()) }) .await?; - if let Some((stored_path, thumbnail_path)) = media_paths { - if !stored_path.is_empty() { - let _ = tokio::fs::remove_file(stored_path).await; - } - if let Some(thumbnail_path) = thumbnail_path.filter(|path| !path.is_empty()) { - let _ = tokio::fs::remove_file(thumbnail_path).await; + for path in paths_to_remove { + if let Err(error) = tokio::fs::remove_file(&path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + media_id, + path = %path.display(), + error = %error, + "failed to remove unreferenced media file after media deletion" + ); } - pool.call(move |conn| { - conn.execute("DELETE FROM media WHERE id = ?", [media_id])?; - Ok(()) - }) - .await?; } Ok(()) } +fn promote_canonical_references( + tx: &rusqlite::Transaction<'_>, + media_id: i64, +) -> anyhow::Result<()> { + let replacement: Option = tx + .query_row( + "SELECT id FROM media WHERE canonical_media_id = ? ORDER BY id ASC LIMIT 1", + [media_id], + |row| row.get(0), + ) + .optional()?; + let Some(replacement) = replacement else { + return Ok(()); + }; + tx.execute( + "UPDATE media SET original_sha256 = '', normalized_sha256 = NULL WHERE id = ?", + [media_id], + )?; + tx.execute( + "UPDATE media SET canonical_media_id = NULL WHERE id = ?", + [replacement], + )?; + tx.execute( + "UPDATE media SET canonical_media_id = ? WHERE canonical_media_id = ?", + params![replacement, media_id], + )?; + Ok(()) +} + pub async fn set_media_nsfw( pool: &SqlitePool, media_ids: &[i64], @@ -394,23 +1016,24 @@ async fn convert_or_original( paths: &RuntimePaths, ffmpeg: &FfmpegStatus, original: &Path, - id: &str, + basename: &str, media_kind: MediaKind, original_mime: &str, -) -> (PathBuf, String, String, String, String) { +) -> StoredVariant { if ffmpeg.available { match media_kind { MediaKind::Image if settings.media.convert_images_to_webp && ffmpeg.supports_webp => { - let out = paths.uploads_images.join(format!("{id}.webp")); + let out = unique_media_path(&paths.uploads_images, basename, "webp"); match crate::ffmpeg::convert_image_to_webp(&settings.media, original, &out).await { Ok(stderr) => { - return ( - out, - format!("/uploads/images/{id}.webp"), - "image/webp".to_owned(), - "converted".to_owned(), + return StoredVariant { + public_path: public_upload_path(paths, &out) + .unwrap_or_else(|_| format!("/uploads/images/{basename}.webp")), + path: out, + mime_type: "image/webp".to_owned(), + state: "converted".to_owned(), stderr, - ); + }; } Err(err) => { return original_fallback( @@ -425,16 +1048,17 @@ async fn convert_or_original( } } MediaKind::Video if settings.media.convert_videos_to_webm && ffmpeg.supports_vp9 => { - let out = paths.uploads_videos.join(format!("{id}.webm")); + let out = unique_media_path(&paths.uploads_videos, basename, "webm"); match crate::ffmpeg::convert_video_to_webm(&settings.media, original, &out).await { Ok(stderr) => { - return ( - out, - format!("/uploads/videos/{id}.webm"), - "video/webm".to_owned(), - "converted".to_owned(), + return StoredVariant { + public_path: public_upload_path(paths, &out) + .unwrap_or_else(|_| format!("/uploads/videos/{basename}.webm")), + path: out, + mime_type: "video/webm".to_owned(), + state: "converted".to_owned(), stderr, - ); + }; } Err(err) => { return original_fallback( @@ -457,26 +1081,24 @@ async fn convert_or_original( fn original_fallback( original: &Path, paths: &RuntimePaths, - media_kind: MediaKind, + _media_kind: MediaKind, mime: &str, state: &str, stderr: &str, -) -> (PathBuf, String, String, String, String) { - let base = original - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("upload"); - let public = match media_kind { - MediaKind::Image | MediaKind::Video => format!("/uploads/originals/{base}"), - }; - let path = paths.uploads_originals.join(base); - ( - path, - public, - mime.to_owned(), - state.to_owned(), - stderr.to_owned(), - ) +) -> StoredVariant { + StoredVariant { + path: original.to_owned(), + public_path: public_upload_path(paths, original).unwrap_or_else(|_| { + let base = original + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("upload"); + format!("/uploads/originals/{base}") + }), + mime_type: mime.to_owned(), + state: state.to_owned(), + stderr: stderr.to_owned(), + } } fn stderr_summary_for_db(stderr: &str) -> String { @@ -541,6 +1163,402 @@ mod tests { assert!(classify(&settings, "video/mp4", settings.media.max_video_size + 1).is_err()); } + #[tokio::test] + async fn exact_same_file_uploaded_twice_reuses_canonical_media() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + + let first = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "Photo.PNG", + &tiny_png_bytes(), + ) + .await; + let second = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "Photo.PNG", + &tiny_png_bytes(), + ) + .await; + + let first = media_row(&pool, first).await; + let second = media_row(&pool, second).await; + assert_eq!(second.canonical_media_id, Some(first.id)); + assert_eq!(second.stored_path, first.stored_path); + assert_eq!(second.public_path, first.public_path); + assert_eq!(second.original_sha256, first.original_sha256); + assert!(Path::new(&first.stored_path).exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn existing_transcoded_webm_is_reused_for_matching_mp4_output() { + let (temp, paths, pool, settings, mut ffmpeg, user_id) = media_fixture().await; + let normalized = b"normalized-video"; + let normalized_hash = sha256_hex(normalized); + let canonical_path = paths.uploads_videos.join("clip-canonical.webm"); + tokio::fs::write(&canonical_path, normalized) + .await + .expect("canonical webm"); + let canonical_id = insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &canonical_path, + public_path: "/uploads/videos/clip-canonical.webm", + mime_type: "video/webm", + media_kind: "video", + original_sha256: "seed-original", + normalized_sha256: &normalized_hash, + }, + ) + .await; + ffmpeg.available = true; + ffmpeg.supports_vp9 = true; + let mut settings = settings; + settings.media.ffmpeg_path = fake_ffmpeg_output(&temp, normalized).display().to_string(); + + let uploaded = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "clip.mp4", + &tiny_mp4_bytes(), + ) + .await; + + let row = media_row(&pool, uploaded).await; + assert_eq!(row.canonical_media_id, Some(canonical_id)); + assert_eq!(row.stored_path, canonical_path.to_string_lossy()); + assert!(!paths.uploads_videos.join("clip.mp4").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn different_image_formats_with_same_webp_output_reuse_canonical() { + let (temp, paths, pool, mut settings, mut ffmpeg, user_id) = media_fixture().await; + let normalized = b"normalized-image"; + settings.media.ffmpeg_path = fake_ffmpeg_output(&temp, normalized).display().to_string(); + ffmpeg.available = true; + ffmpeg.supports_webp = true; + + let png = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "image.png", + &tiny_png_bytes(), + ) + .await; + let jpg = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "image.jpg", + &tiny_jpeg_bytes(), + ) + .await; + + let png = media_row(&pool, png).await; + let jpg = media_row(&pool, jpg).await; + assert_eq!(jpg.canonical_media_id, Some(png.id)); + assert_eq!(jpg.stored_path, png.stored_path); + assert_eq!(jpg.normalized_sha256, png.normalized_sha256); + } + + #[tokio::test] + async fn missing_canonical_file_disables_duplicate_reuse_safely() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + let raw_hash = sha256_hex(&tiny_png_bytes()); + let missing = paths.uploads_images.join("missing.webp"); + let canonical_id = insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &missing, + public_path: "/uploads/images/missing.webp", + mime_type: "image/webp", + media_kind: "image", + original_sha256: &raw_hash, + normalized_sha256: &raw_hash, + }, + ) + .await; + + let uploaded = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "photo.png", + &tiny_png_bytes(), + ) + .await; + + let row = media_row(&pool, uploaded).await; + assert_ne!(row.id, canonical_id); + assert_eq!(row.canonical_media_id, None); + assert_ne!(row.stored_path, missing.to_string_lossy()); + assert!(Path::new(&row.stored_path).exists()); + } + + #[tokio::test] + async fn misleading_canonical_extension_disables_duplicate_reuse_safely() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + let raw = tiny_png_bytes(); + let raw_hash = sha256_hex(&raw); + let misleading = paths.uploads_originals.join("photo.mp4"); + tokio::fs::write(&misleading, &raw) + .await + .expect("misleading canonical file"); + let canonical_id = insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &misleading, + public_path: "/uploads/originals/photo.mp4", + mime_type: "image/png", + media_kind: "image", + original_sha256: &raw_hash, + normalized_sha256: &raw_hash, + }, + ) + .await; + + let uploaded = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "photo.png", + &raw, + ) + .await; + + let row = media_row(&pool, uploaded).await; + assert_ne!(row.id, canonical_id); + assert_eq!(row.canonical_media_id, None); + assert_ne!(row.stored_path, misleading.to_string_lossy()); + assert!(Path::new(&row.stored_path).exists()); + } + + #[tokio::test] + async fn deleting_one_shared_media_row_keeps_canonical_file_until_last_reference() { + let (_temp, paths, pool, _settings, _ffmpeg, user_id) = media_fixture().await; + let shared = paths.uploads_images.join("shared.webp"); + tokio::fs::write(&shared, b"shared").await.expect("shared"); + let shared_hash = sha256_hex(b"shared"); + let first = insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &shared, + public_path: "/uploads/images/shared.webp", + mime_type: "image/webp", + media_kind: "image", + original_sha256: "first", + normalized_sha256: &shared_hash, + }, + ) + .await; + let second = insert_duplicate_row(&pool, user_id, first, &shared).await; + + delete_media(&pool, second).await.expect("delete duplicate"); + assert!(shared.exists()); + delete_media(&pool, first).await.expect("delete canonical"); + assert!(!shared.exists()); + } + + #[tokio::test] + async fn deleting_canonical_media_promotes_remaining_shared_reference() { + let (_temp, paths, pool, _settings, _ffmpeg, user_id) = media_fixture().await; + let shared = paths.uploads_images.join("promoted.webp"); + tokio::fs::write(&shared, b"shared").await.expect("shared"); + let shared_hash = sha256_hex(b"shared"); + let first = insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &shared, + public_path: "/uploads/images/promoted.webp", + mime_type: "image/webp", + media_kind: "image", + original_sha256: "first", + normalized_sha256: &shared_hash, + }, + ) + .await; + let second = insert_duplicate_row(&pool, user_id, first, &shared).await; + + delete_media(&pool, first).await.expect("delete canonical"); + + assert!(shared.exists()); + let promoted = media_row(&pool, second).await; + assert_eq!(promoted.canonical_media_id, None); + assert_eq!(promoted.stored_path, shared.to_string_lossy()); + } + + #[tokio::test] + async fn different_files_with_same_name_do_not_collide() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + let mut other_png = tiny_png_bytes(); + other_png.push(0); + + let first = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "same.png", + &tiny_png_bytes(), + ) + .await; + let second = save_test_upload( + &pool, &settings, &paths, &ffmpeg, user_id, "same.png", &other_png, + ) + .await; + + let first = media_row(&pool, first).await; + let second = media_row(&pool, second).await; + assert_ne!(first.stored_path, second.stored_path); + assert_ne!(first.public_path, second.public_path); + assert!(Path::new(&first.stored_path).exists()); + assert!(Path::new(&second.stored_path).exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn different_media_types_never_dedupe_into_each_other() { + let (temp, paths, pool, mut settings, mut ffmpeg, user_id) = media_fixture().await; + let normalized = b"same-normalized-output"; + let normalized_hash = sha256_hex(normalized); + let image_path = paths.uploads_images.join("image.webp"); + tokio::fs::write(&image_path, normalized) + .await + .expect("image"); + insert_canonical_media( + &pool, + user_id, + TestCanonicalMedia { + stored_path: &image_path, + public_path: "/uploads/images/image.webp", + mime_type: "image/webp", + media_kind: "image", + original_sha256: "seed-image", + normalized_sha256: &normalized_hash, + }, + ) + .await; + settings.media.ffmpeg_path = fake_ffmpeg_output(&temp, normalized).display().to_string(); + ffmpeg.available = true; + ffmpeg.supports_vp9 = true; + + let video = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "clip.mp4", + &tiny_mp4_bytes(), + ) + .await; + + let row = media_row(&pool, video).await; + assert_eq!(row.media_kind, "video"); + assert_eq!(row.canonical_media_id, None); + assert_ne!(row.stored_path, image_path.to_string_lossy()); + } + + #[cfg(unix)] + #[tokio::test] + async fn original_and_transcoded_variants_share_stable_basename() { + let (temp, paths, pool, mut settings, mut ffmpeg, user_id) = media_fixture().await; + settings.media.keep_original_uploads = true; + settings.media.ffmpeg_path = fake_ffmpeg_output(&temp, b"webp-output") + .display() + .to_string(); + ffmpeg.available = true; + ffmpeg.supports_webp = true; + ffmpeg.supports_vp9 = true; + + let media_id = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "My Photo.PNG", + &tiny_png_bytes(), + ) + .await; + + let row = media_row(&pool, media_id).await; + let original_path = row.original_path.expect("original path"); + let original_stem = Path::new(&original_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("original stem"); + let stored_stem = Path::new(&row.stored_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("stored stem"); + assert_eq!(original_stem, stored_stem); + assert!(original_stem.starts_with("my-photo-")); + assert!(row.public_path.ends_with(&format!("{stored_stem}.webp"))); + assert!( + row.original_public_path + .unwrap() + .ends_with(&format!("{original_stem}.png")) + ); + + let video_id = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "My Clip.MP4", + &tiny_mp4_bytes(), + ) + .await; + let video = media_row(&pool, video_id).await; + let original_path = video.original_path.expect("video original path"); + let original_stem = Path::new(&original_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("video original stem"); + let stored_stem = Path::new(&video.stored_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("video stored stem"); + assert_eq!(original_stem, stored_stem); + assert!(original_stem.starts_with("my-clip-")); + assert!(video.public_path.ends_with(&format!("{stored_stem}.webm"))); + assert!( + video + .original_public_path + .unwrap() + .ends_with(&format!("{original_stem}.mp4")) + ); + } + #[tokio::test] async fn profile_media_replacement_deletes_previous_file() { let temp = tempfile::tempdir().expect("temp dir"); @@ -694,6 +1712,187 @@ mod tests { assert!(thumbnail_public_path.is_none()); } + #[derive(Debug)] + struct MediaRow { + id: i64, + original_path: Option, + original_public_path: Option, + stored_path: String, + public_path: String, + media_kind: String, + original_sha256: String, + normalized_sha256: Option, + canonical_media_id: Option, + } + + async fn media_fixture() -> ( + tempfile::TempDir, + RuntimePaths, + SqlitePool, + Settings, + FfmpegStatus, + i64, + ) { + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + paths.ensure().expect("paths"); + let pool = crate::db::connect(&paths.database_path) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let ffmpeg = FfmpegStatus { + available: false, + version: String::new(), + supports_webp: false, + supports_vp9: false, + error: Some("disabled in tests".to_owned()), + }; + let user_id = + crate::auth::register_user(&pool, &settings, "alice", "very secure password", false) + .await + .expect("user"); + (temp, paths, pool, settings, ffmpeg, user_id) + } + + async fn save_test_upload( + pool: &SqlitePool, + settings: &Settings, + paths: &RuntimePaths, + ffmpeg: &FfmpegStatus, + user_id: i64, + filename: &str, + bytes: &[u8], + ) -> i64 { + let staging = paths.staged_upload_path(&Uuid::new_v4().simple().to_string()); + tokio::fs::write(&staging, bytes).await.expect("staging"); + let context = UploadContext { + pool, + settings, + paths, + ffmpeg, + }; + save_staged_upload( + &context, + StagedUpload { + owner_user_id: Some(user_id), + original_filename: filename.to_owned(), + staging, + bytes: u64::try_from(bytes.len()).expect("byte len"), + }, + ) + .await + .expect("save upload") + } + + async fn media_row(pool: &SqlitePool, media_id: i64) -> MediaRow { + pool.call(move |conn| { + conn.query_row( + "SELECT id, original_path, original_public_path, stored_path, public_path, media_kind, original_sha256, normalized_sha256, canonical_media_id FROM media WHERE id = ?", + [media_id], + |row| { + Ok(MediaRow { + id: row.get(0)?, + original_path: row.get(1)?, + original_public_path: row.get(2)?, + stored_path: row.get(3)?, + public_path: row.get(4)?, + media_kind: row.get(5)?, + original_sha256: row.get(6)?, + normalized_sha256: row.get(7)?, + canonical_media_id: row.get(8)?, + }) + }, + ) + .map_err(Into::into) + }) + .await + .expect("media row") + } + + struct TestCanonicalMedia<'a> { + stored_path: &'a Path, + public_path: &'a str, + mime_type: &'a str, + media_kind: &'a str, + original_sha256: &'a str, + normalized_sha256: &'a str, + } + + async fn insert_canonical_media( + pool: &SqlitePool, + user_id: i64, + media: TestCanonicalMedia<'_>, + ) -> i64 { + let stored_path = media.stored_path.to_string_lossy().to_string(); + let public_path = media.public_path.to_owned(); + let mime_type = media.mime_type.to_owned(); + let media_kind = media.media_kind.to_owned(); + let original_sha256 = media.original_sha256.to_owned(); + let normalized_sha256 = media.normalized_sha256.to_owned(); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256) VALUES (?, 'canonical', ?, ?, ?, ?, 1, ?, ?)", + params![ + user_id, + stored_path, + public_path, + mime_type, + media_kind, + original_sha256, + normalized_sha256, + ], + )?; + Ok(conn.last_insert_rowid()) + }) + .await + .expect("canonical media") + } + + async fn insert_duplicate_row( + pool: &SqlitePool, + user_id: i64, + canonical_id: i64, + stored_path: &Path, + ) -> i64 { + let stored_path = stored_path.to_string_lossy().to_string(); + pool.call(move |conn| { + conn.execute( + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, canonical_media_id) VALUES (?, 'duplicate', ?, '/uploads/images/shared.webp', 'image/webp', 'image', 1, ?)", + params![user_id, stored_path, canonical_id], + )?; + Ok(conn.last_insert_rowid()) + }) + .await + .expect("duplicate media") + } + + fn tiny_png_bytes() -> Vec { + vec![ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, + 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, + 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x04, 0x00, 0x01, 0xfe, 0xa7, 0x69, 0x9d, + 0x16, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ] + } + + fn tiny_jpeg_bytes() -> Vec { + vec![ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0x00, 0x01, 0x01, 0x00, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xd9, + ] + } + + fn tiny_mp4_bytes() -> Vec { + let mut bytes = vec![ + 0x00, 0x00, 0x00, 0x18, b'f', b't', b'y', b'p', b'i', b's', b'o', b'm', 0x00, 0x00, + 0x00, 0x00, b'i', b's', b'o', b'm', b'm', b'p', b'4', b'2', + ]; + bytes.extend_from_slice(b"rustpost-test-video"); + bytes + } + async fn insert_test_media( pool: &SqlitePool, user_id: i64, @@ -728,10 +1927,19 @@ mod tests { #[cfg(unix)] fn fake_ffmpeg(temp: &tempfile::TempDir) -> PathBuf { + fake_ffmpeg_output(temp, b"webp") + } + + #[cfg(unix)] + fn fake_ffmpeg_output(temp: &tempfile::TempDir, output: &[u8]) -> PathBuf { let path = temp.path().join("fake-ffmpeg"); + let output = std::str::from_utf8(output).expect("fake output utf8"); + assert!(!output.contains('\'')); std::fs::write( &path, - "#!/bin/sh\nlast=\"\"\nfor arg do\n last=\"$arg\"\ndone\nprintf webp > \"$last\"\n", + format!( + "#!/bin/sh\nlast=\"\"\nfor arg do\n last=\"$arg\"\ndone\nprintf '%s' '{output}' > \"$last\"\n" + ), ) .expect("fake ffmpeg"); let mut permissions = std::fs::metadata(&path) From 8c2ebffbd2d45a3d415a350db46ebf0d48929e51 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 23 May 2026 19:28:07 -0700 Subject: [PATCH 21/23] Add YouTube link previews in posts Detect YouTube links in post text and render static preview cards (thumbnail + link) server-side. Added parsing/validation helpers (youtube_preview_from_url, youtube_video_id_from_parts, etc.), rendering helpers (render_youtube_previews, render_youtube_preview_card) and a YoutubePreview struct, plus constants to limit previews per post. Updated post_card to include previews and added Uri import. Broadened Content-Security-Policy to allow thumbnails from https://i.ytimg.com and added CSS for preview styling. Included comprehensive unit tests for extraction, rendering, escaping and preview limits. --- src/render.rs | 289 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 285 insertions(+), 4 deletions(-) diff --git a/src/render.rs b/src/render.rs index c598c6a..5ecd962 100644 --- a/src/render.rs +++ b/src/render.rs @@ -2,9 +2,14 @@ use crate::auth::{CurrentUser, Theme}; use crate::social::{ AccountView, MediaView, NotificationView, PostView, QuotePreview, TimelineEventKind, }; -use axum::http::StatusCode; +use axum::http::{StatusCode, Uri}; use chrono::{DateTime, NaiveDateTime, Utc}; +const YOUTUBE_VIDEO_ID_LEN: usize = 11; +// Keep link-heavy posts compact in timelines while still showing useful context. +const MAX_YOUTUBE_PREVIEWS_PER_POST: usize = 3; +const YOUTUBE_PREVIEW_TITLE: &str = "YouTube video"; + #[derive(Debug, Clone)] pub struct LayoutContext { pub anonymous_mode_enabled: bool, @@ -125,7 +130,7 @@ pub fn layout_with_context( - + @@ -1193,6 +1198,7 @@ fn post_card_with_options( }, ); let text = linkify(&post.text); + let youtube_previews = render_youtube_previews(&post.text); let media = post .media .iter() @@ -1295,7 +1301,7 @@ fn post_card_with_options( .as_ref() .map_or_else(String::new, quote_preview_card); format!( - r#"
{}{}
{}
{}
{}
{}
{}{}
{} likes{} reposts{} replies{}
{}
"#, + r#"
{}{}
{}
{}
{}
{}
{}{}{}
{} likes{} reposts{} replies{}
{}
"#, post_class, post.id, post.id, @@ -1307,6 +1313,7 @@ fn post_card_with_options( author, timestamp, text, + youtube_previews, media, quote, post.like_count, @@ -1497,6 +1504,163 @@ fn render_media(media: &MediaView, post_id: i64, index: usize, blur_nsfw_media: ) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct YoutubePreview { + video_id: String, + href: String, + display_url: String, +} + +fn render_youtube_previews(text: &str) -> String { + let previews = youtube_previews_for_text(text); + if previews.is_empty() { + return String::new(); + } + let cards = previews + .iter() + .map(render_youtube_preview_card) + .collect::>() + .join(""); + format!(r#""#) +} + +fn render_youtube_preview_card(preview: &YoutubePreview) -> String { + let href = html_escape::encode_double_quoted_attribute(&preview.href); + let display_url = html_escape::encode_text(&preview.display_url); + let thumbnail = format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", preview.video_id); + format!( + r#"YouTube{}{display_url}"#, + html_escape::encode_double_quoted_attribute(&thumbnail), + html_escape::encode_text(YOUTUBE_PREVIEW_TITLE), + ) +} + +fn youtube_previews_for_text(text: &str) -> Vec { + let mut previews = Vec::new(); + for word in text.split_whitespace() { + if previews.len() >= MAX_YOUTUBE_PREVIEWS_PER_POST { + break; + } + if let Some(candidate) = youtube_candidate_from_word(word) + && let Some(preview) = youtube_preview_from_url(candidate) + { + previews.push(preview); + } + } + previews +} + +fn youtube_candidate_from_word(word: &str) -> Option<&str> { + let candidate = word + .trim_start_matches(is_url_leading_trim_char) + .trim_end_matches(is_url_trailing_trim_char); + (!candidate.is_empty()).then_some(candidate) +} + +fn is_url_leading_trim_char(ch: char) -> bool { + matches!(ch, '<' | '(' | '[' | '{' | '"' | '\'') +} + +fn is_url_trailing_trim_char(ch: char) -> bool { + matches!( + ch, + '.' | ',' | '!' | '?' | ':' | ';' | ')' | ']' | '}' | '>' | '"' | '\'' + ) +} + +fn youtube_preview_from_url(candidate: &str) -> Option { + let href = youtube_absolute_http_url(candidate)?; + let uri = href.parse::().ok()?; + if !matches!(uri.scheme_str(), Some("http" | "https")) { + return None; + } + let host = uri.host()?.to_ascii_lowercase(); + let video_id = youtube_video_id_from_parts(&host, uri.path(), uri.query())?; + Some(YoutubePreview { + video_id, + href, + display_url: candidate.to_owned(), + }) +} + +fn youtube_absolute_http_url(candidate: &str) -> Option { + if has_http_scheme(candidate) { + return Some(candidate.to_owned()); + } + let (host, _path) = candidate.split_once('/')?; + is_supported_youtube_host(host).then(|| format!("https://{candidate}")) +} + +fn has_http_scheme(candidate: &str) -> bool { + candidate + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")) + || candidate + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) +} + +fn youtube_video_id_from_parts(host: &str, path: &str, query: Option<&str>) -> Option { + if is_youtu_be_host(host) { + return single_path_segment(path).and_then(valid_youtube_video_id); + } + if !is_youtube_host(host) { + return None; + } + if path == "/watch" { + return query.and_then(youtube_video_id_from_query); + } + path_segment_after_prefix(path, "/shorts/") + .or_else(|| path_segment_after_prefix(path, "/embed/")) + .and_then(valid_youtube_video_id) +} + +fn youtube_video_id_from_query(query: &str) -> Option { + let mut video_id = None; + for pair in query.split('&') { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + if key != "v" { + continue; + } + let valid = valid_youtube_video_id(value)?; + if video_id.replace(valid).is_some() { + return None; + } + } + video_id +} + +fn single_path_segment(path: &str) -> Option<&str> { + let value = path.strip_prefix('/')?; + (!value.is_empty() && !value.contains('/')).then_some(value) +} + +fn path_segment_after_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> { + let value = path.strip_prefix(prefix)?; + (!value.is_empty() && !value.contains('/')).then_some(value) +} + +fn valid_youtube_video_id(value: &str) -> Option { + (value.len() == YOUTUBE_VIDEO_ID_LEN + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))) + .then(|| value.to_owned()) +} + +fn is_supported_youtube_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + is_youtube_host(&host) || is_youtu_be_host(&host) +} + +fn is_youtube_host(host: &str) -> bool { + matches!(host, "youtube.com" | "www.youtube.com" | "m.youtube.com") +} + +fn is_youtu_be_host(host: &str) -> bool { + host == "youtu.be" +} + pub fn linkify(text: &str) -> String { html_escape::encode_text(text) .split_whitespace() @@ -1824,7 +1988,7 @@ input:focus,textarea:focus,select:focus,button:focus-visible,a:focus-visible{out nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.42rem .65rem}.rail-nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.5rem .65rem}.rail-nav button:hover,.mobile-nav button:hover{background:var(--hover);color:var(--link-strong)} .composer-surface{border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);overflow:hidden}.composer-surface textarea{border:0;border-radius:0;background:transparent;min-height:7rem;resize:vertical}.composer-surface textarea:focus{outline:0;box-shadow:inset 0 0 0 3px var(--focus)}.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:.55rem;border-top:1px solid var(--border);padding:.42rem .5rem;background:var(--surface-subtle)}.composer-file-control{position:relative;display:inline-flex;align-items:center;gap:.45rem;max-width:100%;margin:0;color:var(--link-strong);font-weight:800}.composer-file-input{max-width:100%;color:var(--muted-strong)}.composer-file-input::file-selector-button{border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.34rem .58rem;font-weight:800;cursor:pointer}.composer-file-input::file-selector-button:hover{background:var(--hover);color:var(--text-strong)}.composer-file-button{display:none}.js-enabled .composer-file-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.js-enabled .composer-file-button{display:inline-flex;align-items:center;gap:.38rem;min-height:2rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.3rem .55rem;cursor:pointer}.composer-file-button svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.js-enabled .composer-file-control:hover .composer-file-button{background:var(--hover);color:var(--text-strong)}.js-enabled .composer-file-input:focus-visible+.composer-file-button{outline:3px solid var(--focus);outline-offset:2px}.composer-media-selection[hidden]{display:none}.composer-media-selection{display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;border-top:1px solid var(--border);padding:.5rem;background:var(--surface)}.composer-media-summary{font-weight:800;color:var(--muted-strong);overflow-wrap:anywhere}.composer-nsfw{margin:0}.composer-clear-media{background:var(--surface);color:var(--link-strong);border-color:var(--border);padding:.32rem .55rem}.composer-clear-media:hover{background:var(--hover);color:var(--text-strong)}.composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem} .thread-nav{display:flex;margin:0 0 .45rem .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none} -.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px}.nsfw-media{position:relative;margin-top:.5rem}.nsfw-media .post img,.nsfw-media .post video{margin-top:0}.nsfw-media-frame{position:relative;display:block;overflow:hidden;border-radius:8px}.nsfw-media-frame img,.nsfw-media-frame video{margin-top:0;filter:blur(24px);transform:scale(1.02)}.nsfw-toggle:checked+.nsfw-media-frame img,.nsfw-toggle:checked+.nsfw-media-frame video{filter:none;transform:none}.nsfw-badge{position:absolute;left:.55rem;bottom:.55rem;border-radius:999px;padding:.18rem .5rem;background:rgba(0,0,0,.72);color:#fff;font-size:.78rem;font-weight:900;letter-spacing:.03em}.nsfw-show{position:absolute;right:.55rem;bottom:.55rem;margin:0;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text-strong);padding:.32rem .65rem;font-weight:900;box-shadow:0 1px 2px var(--shadow);cursor:pointer}.nsfw-show:hover{background:var(--hover)}.nsfw-toggle:focus-visible~.nsfw-show{outline:3px solid var(--focus);outline-offset:2px}.nsfw-toggle:checked~.nsfw-show,.nsfw-toggle:checked+.nsfw-media-frame .nsfw-badge{display:none}.nsfw-open{display:block;margin:.35rem 0 0;font-size:.9rem;font-weight:700} +.timeline{display:grid;gap:.65rem}.post{overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px}.youtube-previews{display:grid;gap:.45rem;margin:.35rem 0 .55rem}.youtube-preview-card{display:grid;grid-template-columns:minmax(5.5rem,7.5rem) minmax(0,1fr);gap:.65rem;align-items:center;min-height:4.5rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);color:var(--text);overflow:hidden}.youtube-preview-card:hover{border-color:var(--border-strong);background:var(--hover);text-decoration:none}.youtube-thumbnail-frame{position:relative;display:block;width:100%;aspect-ratio:16/9;overflow:hidden;background:var(--media-bg)}.post img.youtube-thumbnail{width:100%;height:100%;object-fit:cover;margin:0;border:0;border-radius:0}.youtube-play{position:absolute;left:50%;top:50%;width:2rem;height:2rem;border-radius:999px;background:rgba(0,0,0,.68);transform:translate(-50%,-50%)}.youtube-play::before{content:"";position:absolute;left:.78rem;top:.55rem;border-top:.45rem solid transparent;border-bottom:.45rem solid transparent;border-left:.65rem solid #fff}.youtube-preview-body{display:grid;gap:.08rem;min-width:0;padding:.45rem .55rem .45rem 0}.youtube-preview-source{color:var(--muted);font-size:.78rem;font-weight:900;text-transform:uppercase}.youtube-preview-title{color:var(--text-strong);font-weight:850;overflow-wrap:anywhere}.youtube-preview-url{color:var(--muted-strong);font-size:.86rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nsfw-media{position:relative;margin-top:.5rem}.nsfw-media .post img,.nsfw-media .post video{margin-top:0}.nsfw-media-frame{position:relative;display:block;overflow:hidden;border-radius:8px}.nsfw-media-frame img,.nsfw-media-frame video{margin-top:0;filter:blur(24px);transform:scale(1.02)}.nsfw-toggle:checked+.nsfw-media-frame img,.nsfw-toggle:checked+.nsfw-media-frame video{filter:none;transform:none}.nsfw-badge{position:absolute;left:.55rem;bottom:.55rem;border-radius:999px;padding:.18rem .5rem;background:rgba(0,0,0,.72);color:#fff;font-size:.78rem;font-weight:900;letter-spacing:.03em}.nsfw-show{position:absolute;right:.55rem;bottom:.55rem;margin:0;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text-strong);padding:.32rem .65rem;font-weight:900;box-shadow:0 1px 2px var(--shadow);cursor:pointer}.nsfw-show:hover{background:var(--hover)}.nsfw-toggle:focus-visible~.nsfw-show{outline:3px solid var(--focus);outline-offset:2px}.nsfw-toggle:checked~.nsfw-show,.nsfw-toggle:checked+.nsfw-media-frame .nsfw-badge{display:none}.nsfw-open{display:block;margin:.35rem 0 0;font-size:.9rem;font-weight:700} .counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.post-permalink{font-weight:700;color:var(--link-strong)}.js-enabled .post-permalink{display:none}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.admin-nsfw-button{min-height:2.2rem;padding:.3rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border);font-size:.86rem}.admin-nsfw-button:hover{background:var(--hover);color:var(--text-strong)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem}.noscript-banner{max-width:1100px;margin:.7rem auto 0;padding:.65rem .85rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);color:var(--muted-strong)}.js-enabled .noscript-banner{display:none} .profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:0 .85rem .85rem}.settings-profile-media{margin:0 -.85rem .85rem}.settings-banner-wrap{background:var(--media-bg)}.settings-banner-preview{display:block;width:100%;height:220px;object-fit:cover;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:0;border-radius:0}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;padding:0 .85rem .85rem;margin-top:-48px}.settings-picture-preview{width:112px;height:112px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.5rem;align-content:end;padding-top:3.25rem}.media-control-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.settings-fields{display:grid;gap:.25rem}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0 0 .65rem;font-size:1.1rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.2rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form button[type=submit]{margin-top:.9rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p{max-width:62ch}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-list{display:grid;gap:.65rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%} @media (max-width:1100px){.app-shell{--shell-side:220px;--shell-max:880px;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary))}.right-rail{display:none}} @@ -2362,6 +2526,123 @@ mod tests { assert!(body.contains(r#"alert(1) https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1" + .to_owned(); + + let body = post_card(&post, None, None); + + assert!(body.contains("unsafe <script>alert(1)</script>")); + assert!(!body.contains("")); + assert!(body.contains(r#"href="https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1""#)); + assert!(body.contains( + r#"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1"# + )); + } + + #[test] + fn posts_without_youtube_links_do_not_render_preview_cards() { + let mut post = test_post(); + post.text = "hello https://example.com/watch?v=dQw4w9WgXcQ #rust".to_owned(); + + let body = post_card(&post, None, None); + + assert!(!body.contains("youtube-preview-card")); + assert!(body.contains( + r#"
hello https://example.com/watch?v=dQw4w9WgXcQ #rust
"# + )); + } + + #[test] + fn multiple_youtube_links_render_up_to_documented_cap() { + let mut post = test_post(); + post.text = "https://youtu.be/dQw4w9WgXcQ https://youtube.com/shorts/aaaaaaaaaaa https://youtube.com/embed/bbbbbbbbbbb https://youtube.com/watch?v=ccccccccccc".to_owned(); + + let body = post_card(&post, None, None); + + assert_eq!( + body.matches(r#"data-testid="youtube-preview-card""#) + .count(), + 3 + ); + assert!(body.contains("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg")); + assert!(body.contains("https://i.ytimg.com/vi/aaaaaaaaaaa/hqdefault.jpg")); + assert!(body.contains("https://i.ytimg.com/vi/bbbbbbbbbbb/hqdefault.jpg")); + assert!(!body.contains("https://i.ytimg.com/vi/ccccccccccc/hqdefault.jpg")); + } + + #[test] + fn no_javascript_youtube_preview_shows_thumbnail_and_link_card() { + let mut post = test_post(); + post.text = "watch https://youtu.be/dQw4w9WgXcQ".to_owned(); + + let body = post_card(&post, None, None); + + assert!(body.contains( + r#""# + )); + assert!(body.contains(r#"YouTube video"#)); + assert!(!body.contains("body

", "RustPost"); + + assert!(body.contains("img-src 'self' data: https://i.ytimg.com")); + } + fn test_post() -> PostView { PostView { event_id: "p:42".to_owned(), From 9683d55c98f03f9f19798e8e77b442d007655a89 Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 23 May 2026 19:59:21 -0700 Subject: [PATCH 22/23] Update changelog and fix media test unwraps --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ src/media.rs | 19 ++++++++----------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be59be..99ca029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## v0.1.5 - Safety, Media, and Link Previews + +### Posts and Composer +- Added YouTube link previews for posts so shared video links render with richer inline context. +- Polished the post composer UI and tightened the shared shell sidebar spacing. +- Added a compact quote action icon while preserving the existing posting flow. +- Added a no-JS banner and progressive enhancement fallback behavior for interactive page controls. + +### Media Safety and Storage +- Added NSFW media marking, blur-by-default rendering, per-user NSFW blur preferences, and admin controls for marking existing media posts. +- Added a migration and settings support for global NSFW blur defaults. +- Added duplicate media detection with canonical media reuse and cleanup so repeated uploads can share stored variants safely. +- Improved original upload handling so original and transcoded variants share stable basenames. + +### Account and Admin Controls +- Added optional registration CAPTCHA support with single-use challenge validation. +- Exposed CAPTCHA and NSFW blur settings through deep admin settings. +- Expanded the admin users investigation panel with richer search and post-context tooling. + +### Privacy, Networking, and Performance +- Added gzip compression for browser text responses while leaving media and binary uploads uncompressed. +- Added configurable Tor mirror display in the header and refined the Tor header into a compact status link. +- Kept the embedded Tor client alive through shared ownership for more reliable background onion service operation. + +### Release Prep +- Bumped the crate version to `0.1.5` and refreshed `Cargo.lock`. +- Preserved the ignored Playwright artifact policy while removing tracked Playwright test artifacts from the branch. +- Fixed focused Clippy findings in the composer, CAPTCHA, media, and rendering paths. + ## v0.1.4 - Layout Foundation ### Core UI Layout diff --git a/src/media.rs b/src/media.rs index 7e00622..cdbcf17 100644 --- a/src/media.rs +++ b/src/media.rs @@ -1522,11 +1522,10 @@ mod tests { assert_eq!(original_stem, stored_stem); assert!(original_stem.starts_with("my-photo-")); assert!(row.public_path.ends_with(&format!("{stored_stem}.webp"))); - assert!( - row.original_public_path - .unwrap() - .ends_with(&format!("{original_stem}.png")) - ); + assert!(matches!( + row.original_public_path.as_deref(), + Some(path) if path.ends_with(&format!("{original_stem}.png")) + )); let video_id = save_test_upload( &pool, @@ -1551,12 +1550,10 @@ mod tests { assert_eq!(original_stem, stored_stem); assert!(original_stem.starts_with("my-clip-")); assert!(video.public_path.ends_with(&format!("{stored_stem}.webm"))); - assert!( - video - .original_public_path - .unwrap() - .ends_with(&format!("{original_stem}.mp4")) - ); + assert!(matches!( + video.original_public_path.as_deref(), + Some(path) if path.ends_with(&format!("{original_stem}.mp4")) + )); } #[tokio::test] From 48da44a1740cb2a7cb5ffedfb7b4e0571e0a353d Mon Sep 17 00:00:00 2001 From: csd113 Date: Sat, 23 May 2026 21:28:38 -0700 Subject: [PATCH 23/23] Fix Clippy clone assignment in media upload path --- src/media.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/media.rs b/src/media.rs index cdbcf17..a7e108d 100644 --- a/src/media.rs +++ b/src/media.rs @@ -238,7 +238,7 @@ async fn store_new_upload( let mut basename = stable_media_basename(&upload.original_filename, &upload.original_sha256); let original_path = unique_media_path(&context.paths.uploads_originals, &basename, ext); if let Some(stem) = original_path.file_stem().and_then(|stem| stem.to_str()) { - basename = stem.to_owned(); + stem.clone_into(&mut basename); } let original_public_path = public_upload_path(context.paths, &original_path)?; tokio::fs::rename(&upload.staging, &original_path).await?;