public url option, del hotkey, keyboard focus fixes
--public-url / FILEBROWSER_PUBLIC_URL: optional base for share and WebDAV links. Sent to the UI in /me; unset keeps the page-origin links. Browser keyboard: - Delete opens the delete confirm for the selection in writable roots. - Arrow keys work while no row has focus: continue from the first selected entry, or select the first entry. - Closing a preview refocuses the selected row. Left/Right in a preview now moves the selection with the shown file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MREADME.md
@@ -71,6 +71,7 @@ filebrowser-ng --root /srv/files --db /var/lib/filebrowser/db.sqlite --bind 0.0.
| `--bind` | `127.0.0.1` | Listen address. `0.0.0.0` exposes the server beyond localhost. |
| `--https` | off | Set when behind a TLS-terminating proxy. Marks the cookie `Secure`. Also `FILEBROWSER_HTTPS=true`. |
| `--root-name` | folder name | Display name of the root folder. Also `FILEBROWSER_ROOT_NAME`. |
| `--public-url` | off | Public base URL, e.g. `https://files.example.com`. Share and WebDAV links are built from it instead of the browser's address. Also `FILEBROWSER_PUBLIC_URL`. |
| `--cache` | off | Folder for the thumbnail cache. Turns thumbnails on. Also `FILEBROWSER_CACHE`. |
Log level comes from `RUST_LOG` (`error`, `warn`, `info`, `debug`, `trace`).
Mapi-types/src/lib.rs
@@ -339,6 +339,9 @@ pub struct Me {
/// Whether the server can make thumbnails at all (`--cache` is set).
/// The profile setting is only offered when this is true.
pub thumbnails_available: bool,
/// `--public-url`, if set. The UI builds share links from it instead of
/// the page origin.
pub public_url: Option<String>,
}
/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
Mcompose.yml
@@ -7,6 +7,7 @@ services:
environment:
RUST_LOG: info # server log level: trace/debug/info/warn/error
# FILEBROWSER_HTTPS: "true" # behind a TLS-terminating proxy: marks the session cookie Secure
# FILEBROWSER_PUBLIC_URL: https://files.example.com # base of share links (default: the address the browser uses)
# FILEBROWSER_ROOT_NAME: Media # UI name of the root folder (default: its folder name, here "data")
# FILEBROWSER_CACHE: /var/cache/filebrowser # turns grid thumbnails on; ffmpeg is in the image
volumes:
Mserver/src/api/auth.rs
@@ -31,6 +31,7 @@ pub async fn me(
roots: Vec::new(),
allow_writable_shares: false,
thumbnails_available: state.thumbs.is_some(),
public_url: state.public_url.clone(),
}));
}
@@ -63,6 +64,7 @@ async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me
roots,
allow_writable_shares: state.db.allow_writable_shares().await?,
thumbnails_available: state.thumbs.is_some(),
public_url: state.public_url.clone(),
})
}
Mserver/src/cli.rs
@@ -41,6 +41,12 @@ pub struct Cli {
/// The env form takes `true` or `false`.
#[arg(long, env = "FILEBROWSER_HTTPS")]
pub https: bool,
/// Public base URL of this server, e.g. `https://files.example.com`.
/// Share and WebDAV links in the UI are built from it. Without it they
/// use whatever address the browser is connected to.
#[arg(long, env = "FILEBROWSER_PUBLIC_URL")]
pub public_url: Option<String>,
}
#[cfg(test)]
Mserver/src/error.rs
@@ -15,6 +15,8 @@ pub struct AppState {
pub root_name: String,
/// Whether we sit behind a TLS-terminating reverse proxy.
pub https: bool,
/// `--public-url` with any trailing slash removed; `None` when unset.
pub public_url: Option<String>,
/// Thumbnail cache. `None` when `--cache` is unset, which turns
/// thumbnails off everywhere.
pub thumbs: Option<Arc<crate::thumb::Thumbs>>,
Mserver/src/lib.rs
@@ -60,6 +60,10 @@ pub async fn build_app(cli: &Cli) -> anyhow::Result<(axum::Router, SocketAddr)>
root: root.clone(),
root_name,
https: cli.https,
public_url: cli
.public_url
.as_deref()
.map(|u| u.trim_end_matches('/').to_string()),
thumbs,
});
@@ -142,6 +146,7 @@ mod tests {
bind: "127.0.0.1".into(),
cache: None,
https: false,
public_url: None,
}
}
Mserver/tests/api_auth.rs
@@ -204,6 +204,7 @@ async fn root_name_is_configurable() {
root: env.state.root.clone(),
root_name: "Media".to_string(),
https: false,
public_url: None,
thumbs: None,
});
env.app = server::api::router(state.clone());
@@ -219,3 +220,23 @@ async fn root_name_is_configurable() {
"inner"
);
}
#[tokio::test]
async fn public_url_reaches_client() {
let mut env = Env::new().await;
let state = Arc::new(server::error::AppState {
db: env.state.db.clone(),
root: env.state.root.clone(),
root_name: env.state.root_name.clone(),
https: false,
public_url: Some("https://files.example.com".to_string()),
thumbs: None,
});
env.app = server::api::router(state.clone());
env.state = state;
let admin = env.admin().await;
assert_eq!(
admin.get("/api/auth/me").await.json()["public_url"],
"https://files.example.com"
);
}
Mserver/tests/common/mod.rs
@@ -91,6 +91,7 @@ impl Env {
root: p.canonicalize().unwrap(),
root_name: server::root_file_name(p),
https: false,
public_url: None,
thumbs,
});
let app = server::api::router(state.clone());
Mweb/src/api.rs
@@ -87,8 +87,10 @@ struct ErrBody {
// Auth
// ---------------------------------------------------------------------------
pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
request("GET", AUTH_ME.to_string(), None::<()>)
pub async fn me() -> Result<Me, ApiError> {
let me: Me = request("GET", AUTH_ME.to_string(), None::<()>).await?;
crate::router::set_public_url(me.public_url.clone());
Ok(me)
}
/// PUT /api/auth/me — update the signed-in user's profile settings.
Mweb/src/router.rs
@@ -158,20 +158,40 @@ pub fn navigate(loc: &Location) {
}
}
thread_local! {
/// The server's `--public-url`, set from every `/me` response. `None`
/// means "use the address the browser is connected to".
static PUBLIC_URL: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
}
pub fn set_public_url(url: Option<String>) {
PUBLIC_URL.with(|p| *p.borrow_mut() = url);
}
fn public_url() -> Option<String> {
PUBLIC_URL.with(|p| p.borrow().clone())
}
/// Build the WebDAV mount URL for a share token.
///
/// Origin-relative, not page-relative: the mount is a server route, so it does
/// not live under whatever path the app itself was loaded from. Only folder
/// shares have one; a file share has no collection to mount.
pub fn dav_share_url(token: &str) -> String {
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default();
let origin = public_url().unwrap_or_else(|| {
web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default()
});
format!("{origin}{}/{token}", api_types::DAV_SHARE)
}
/// Build the share link for a token, relative to the current page origin.
/// Build the share link for a token: under the public URL when the server
/// has one, else relative to the current page.
pub fn share_url(token: &str) -> String {
if let Some(p) = public_url() {
return format!("{p}/#/share/{token}");
}
let base = web_sys::window()
.and_then(|w| w.location().href().ok())
.unwrap_or_default();
Mweb/src/views/browser.rs
@@ -220,6 +220,9 @@ pub fn Browser(
return;
};
ev.prevent_default();
// The selection follows the shown file, so closing the preview puts
// focus and the action bar on the file that was actually open.
set_selected.set(vec![e.clone()]);
open_file.set(Some(FileView::Preview(
PreviewTarget {
root_id: cur.root_id,
@@ -289,6 +292,128 @@ pub fn Browser(
});
}
// Delete asks to delete the selection — the same confirm dialog as the
// context menu's Delete. Not while something else owns the keyboard: a
// dialog, the full-page file view, the context menu, or a text field.
{
let owner = ctx_owner.clone();
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Delete"
|| ev.ctrl_key()
|| ev.meta_key()
|| ev.alt_key()
|| dialog.get().is_some()
|| file_view.get().is_some()
|| ctx.get().is_some()
{
return;
}
let in_field = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.is_some_and(|el| matches!(el.tag_name().as_str(), "INPUT" | "TEXTAREA"));
if in_field {
return;
}
let loc_now = loc.get();
let (is_rw, root_id) = me.with(|m| {
let root = m.as_ref().and_then(|m| effective_root(&m.roots, &loc_now));
(
root.is_some_and(|r| r.mode.is_writable()),
root.map(|r| r.id),
)
});
if !is_rw {
return;
}
let action = selected.with(|sel| {
(!sel.is_empty()).then(|| {
action_delete(sel, root_id, loc, fetch, toast, set_dialog, owner.clone())
})
});
if let Some(a) = action {
ev.prevent_default();
a.run(());
}
},
);
}
// Arrow keys while no row has focus (after load, after a click on empty
// space, after a preview closed): continue from the first selected entry,
// or start at the first entry. Only when focus is on the page body, so
// arrows inside a focused button, field or player stay theirs.
crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if !matches!(
ev.key().as_str(),
"ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"
) || dialog.get().is_some()
|| file_view.get().is_some()
|| ctx.get().is_some()
{
return;
}
let on_body = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.active_element())
.is_none_or(|a| a.tag_name() == "BODY");
if !on_body {
return;
}
let ListState::Entries(all) = sorted.get_untracked() else {
return;
};
let Some(container) = listing_container() else {
return;
};
let anchor = selected.with_untracked(|sel| anchor_index(&all, sel));
match anchor {
Some(idx) => {
let grid = view_mode.get_untracked() == ViewMode::Grid;
arrow_key(
&ev,
&container,
&all,
idx,
grid,
shown,
selected,
set_selected,
);
}
None => {
let Some(first) = all.entries.first() else {
return;
};
ev.prevent_default();
set_selected.set(vec![first.clone()]);
focus_item(&container, 0);
}
}
});
// Closing the full-page view unmounted the focused row with the
// listing. Put focus back on the selected entry, once it is rendered
// again, so the arrow keys continue from there.
Effect::new(move |was_open: Option<bool>| {
let open = file_view.get().is_some();
if was_open == Some(true) && !open {
let ListState::Entries(all) = sorted.get_untracked() else {
return open;
};
if let Some(idx) = selected.with_untracked(|sel| anchor_index(&all, sel)) {
request_animation_frame(move || {
if let Some(c) = listing_container() {
focus_item(&c, idx);
}
});
}
}
open
});
view! {
<div class="browser">
{move || {
@@ -901,6 +1026,7 @@ fn entry_callbacks(
#[allow(clippy::too_many_arguments)] // explicit signal props
fn arrow_key(
ev: &web_sys::KeyboardEvent,
container: &web_sys::Element,
all: &Arc<Listing>,
idx: usize,
grid: bool,
@@ -913,14 +1039,7 @@ fn arrow_key(
if ev.ctrl_key() || ev.meta_key() || ev.alt_key() {
return;
}
let Some(container) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.and_then(|el| el.parent_element())
else {
return;
};
let cols = if grid { grid_columns(&container) } else { 1 };
let cols = if grid { grid_columns(container) } else { 1 };
let step = match ev.key().as_str() {
"ArrowUp" => -(cols as isize),
"ArrowDown" => cols as isize,
@@ -949,7 +1068,7 @@ fn arrow_key(
}
if target < shown.get_untracked() {
focus_item(&container, target);
focus_item(container, target);
return;
}
// Reveal whole pages until the target is rendered. The focus call has
@@ -959,9 +1078,25 @@ fn arrow_key(
*n += FILE_PAGE;
}
});
let container = container.clone();
request_animation_frame(move || focus_item(&container, target));
}
/// The rendered listing container. There is one listing per page at most,
/// so the class lookup is unambiguous.
fn listing_container() -> Option<web_sys::Element> {
web_sys::window()?
.document()?
.query_selector(".entries-grid, .entries-list")
.ok()?
}
/// Position of the first selected entry in the listing, if any.
fn anchor_index(all: &Listing, selected: &[Entry]) -> Option<usize> {
let first = selected.first()?;
all.entries.iter().position(|e| e.name == first.name)
}
/// Whether a double click opens: not with a modifier held, because that is
/// two selecting clicks (the browser still fires `dblclick` for them).
fn plain_dblclick(ev: &MouseEvent) -> bool {
@@ -1045,7 +1180,14 @@ fn entries_view(
open_cb.run(());
return;
}
arrow_key(&ev, &all_keys, i, grid, shown, selected, set_selected);
let Some(container) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.and_then(|el| el.parent_element())
else {
return;
};
arrow_key(&ev, &container, &all_keys, i, grid, shown, selected, set_selected);
}
on:contextmenu=on_ctx
title=title