mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 21:03:17 +02:00
* server: add scoped media-token endpoint for Cast/AirPlay auth bypass
Chromecast and Apple TV fetch media URLs as independent HTTP clients
with no session cookie. The existing serve_artifact handler requires
auth_user.require_auth(), so those devices always received 401.
Changes:
- MediaToken struct stored in AppState (Arc<Mutex<HashMap>>), scoped to
a single (archive_id, entry_uid, artifact_index) tuple with a 2-hour TTL
- POST /api/archives/:id/entries/:uid/artifacts/:idx/media-token
requires an authenticated session, verifies the artifact exists,
prunes expired tokens, mints a 43-char URL-safe token, and returns
{ url, expires_in_secs }
- serve_artifact now accepts an optional ?token= query param; a valid
scoped token bypasses require_auth() while a missing/invalid/expired
token falls through to the normal 401 path
- CSP script-src extended to include https://www.gstatic.com so the
Cast sender SDK script (injected lazily by VideoPreview) is not blocked
- 4 new tests: bare-URL still 401, tokenized fetch succeeds without
session cookie, bogus token 401, wrong-artifact-index 401
* frontend: Cast/AirPlay overlay in VideoPreview
When the user opens a video archive entry, VideoPreview now:
1. Issues a signed media token (POST .../artifacts/:idx/media-token) and
uses the returned signed URL as <video src>. This ensures the video
element's src is one that Cast devices and Apple TV can fetch without a
session cookie.
2. Lazily injects the Google Cast SDK script (cast_sender.js from
gstatic.com, now allowed by the updated CSP). Once the SDK reports
available, a <google-cast-launcher> web component appears as an overlay
button in the top-right corner of the video. Selecting a Cast device
triggers loadMedia() with the signed URL and the artifact's MIME type.
3. Detects AirPlay support (webkitShowPlaybackTargetPicker on
HTMLVideoElement) and shows an AirPlay icon button alongside Cast.
The <video> element carries x-webkit-airplay='allow', so Safari's native
controls also surface the AirPlay option. The explicit overlay button
calls webkitShowPlaybackTargetPicker() for consistent placement.
Both buttons are hidden when the respective APIs are unavailable (HTTP
pages, non-Safari for AirPlay, no Cast extension/devices), so there is no
UI regression for users who don't cast.
PreviewPanel now passes contentType (derived from artifact extension) to
VideoPreview so Cast receives a correct MIME type.
New CSS: .video-tv-controls (absolute overlay), .video-tv-btn (frosted
glass icon button), .video-tv-loading (placeholder during token fetch).
* server: fix serve_artifact auth OR logic — bogus token falls back to session
Previously a request carrying ?token=<expired> was immediately rejected
with 401, even if the user held a valid session cookie. This broke
logged-in browser playback after the 2-hour signed-URL window expired,
because VideoPreview uses the signed URL as <video src>.
Fix: compute token_valid first; if the token is absent or invalid, fall
through to auth_user.require_auth() instead of returning early.
Effect: valid token skips session check, invalid/missing token checks
session, both invalid → 401 as before.
Updated the bogus-token-no-session test docstring to clarify it tests
the no-auth path specifically. Added new test:
media_token_bogus_token_with_session_returns_200 — verifies a logged-in
user can still fetch the artifact via a URL carrying a stale token.
* frontend: guard token-fetch effect against stale async resolution
A slow issueMediaToken() response for video A could resolve after the
user selected video B and call setSignedSrc(urlA), making the
preview/Cast play the wrong file.
Add a cancelled flag set in the effect cleanup; both .then and .catch
check it before touching state, so only the most recent src wins.
* frontend: load Cast media immediately if session already exists
Previously the effect only sent video to the TV on SESSION_STARTED /
SESSION_RESUMED events. Two gaps:
1. If a Cast session was already active when signedSrc became ready
(e.g. the SDK resumed a session before the token fetch finished, or
the user switches videos while already casting), nothing was sent.
2. Same gap if castReady fired after an already-established session.
Fix: extract loadMedia(session) and call it against
ctx.getCurrentSession() immediately when castReady + signedSrc are both
truthy, in addition to keeping the event listener for future connects.
* server: staged file-upload endpoint
POST /api/archives/:id/uploads streams a multipart body to a temp file
under the archive's store/temp/ directory and returns a staged_path the
capture pipeline can move into place.
- Routes: /api/archives/:id/uploads (POST, requires auth)
- Body cap: 10 GiB; chunk-streamed to disk, never buffered in memory
- Path-traversal sanitised on the filename field
- Temp files are cleaned up on error paths (disk-leak fix)
- main.rs wires the new route into the server startup
- Cargo: adds the multipart dependency
* frontend: file upload in Capture dialog
Drag-and-drop or 'Upload file' button stages files for archiving:
- File items sit alongside URL rows in the same list; each shows the
original filename, a live progress bar during upload, and a check badge
when ready. The locator input is replaced entirely — no editable field.
- Archive button is disabled until all uploads finish; each file item
contributes to the Archive N count once its upload is done.
- File items are excluded from sessionStorage persistence (they are
transient — the staged server path would be invalid after a reload).
Staged-file cleanup is handled at every exit path so temp/uploads/ does
not accumulate:
• removeRow on an in-progress item aborts the XHR; removeRow on a done
item calls DELETE /archives/:id/uploads.
• Dialog cancel (Escape / Cancel button) aborts all in-flight XHRs and
DELETEs all completed staged files via the close-event handler.
• handleArchive sets isSubmittingRef=true before dialog.close() so the
close handler skips cleanup — the background capture job handles
staged-file removal on success instead.
• uploadFile() returns { promise, abort } so the component can cancel
the XHR without any visible fetch.
api.js additions: uploadFile (XHR with progress + abort), deleteUpload.
(Static assets rebuilt from combined source to include screensharing
changes from this branch.)
* fix: collection enrollment with default_visibility_bits
Two related fixes from feat-file-uploading:
core: fix collection enrollment using default_visibility_bits instead of
entry.visibility — entries were being enrolled with the entry-level
visibility rather than the collection's configured default.
server: allow changing default_visibility_bits on the default collection
— the PATCH handler was incorrectly blocking updates to the default
collection's visibility configuration.
* server: fix unbounded staged-upload disk growth
Two review findings:
P2 — delete staged file on capture failure (routes.rs)
When perform_capture returns Err, the job was marked failed but
staged_upload_path was never removed. With a 10 GiB body cap a few
failed imports could exhaust archive storage before the next restart.
Mirror the success-path cleanup into the Err arm so the file is removed
immediately regardless of outcome.
P1 — periodic staged-upload pruning (main.rs)
The startup prune of temp/uploads/ only ran once, so uploads abandoned
mid-session (browser crash, navigation away) accumulated forever on a
long-running server. Folded the pruning logic into the existing 24 h
maintenance task alongside session cleanup, so stale dirs are swept
continuously without requiring a restart.
* server+frontend: fix staged-upload disk-growth and prune safety
Server (main.rs + routes.rs):
- Extract prune_stale_upload_dirs() helper called by both startup and
the periodic 24h task, eliminating the duplicated loop.
- Sentinel (.uploading) created in the UUID dir before streaming begins;
removed on successful completion; error path uses remove_dir_all so
the partial file and sentinel are cleaned up together.
The periodic prune skips any dir containing .uploading (active XHR).
- Startup prune passes cleanup_stale_sentinels=true: the server has not
started accepting connections yet so any sentinel is a crash remnant —
it is removed and the dir proceeds to the age check, preventing leaked
dirs from a previous crash accumulating forever.
- Staleness measured from the newest non-sentinel child file mtime so a
just-finished slow upload (dir mtime stale, file mtime fresh) is not
pruned before the user can submit it for capture. Empty dirs fall back
to dir mtime.
- Failed captures (Err branch in spawn_blocking) now also delete the
staged file and UUID dir immediately, matching the success path.
Frontend (api.js + CaptureDialog.jsx):
- submitCapture attaches err.status = res.status on non-2xx responses
so callers can distinguish a definite HTTP rejection from a network
error where the response may have been lost.
- submitBgJob catch deletes the staged file only when e.status is set
(server definitively rejected the POST /captures request). A network
error leaves the file in place because the server may have accepted
the job and the response was lost — deleting would race the capture.
160 lines
6.3 KiB
Rust
160 lines
6.3 KiB
Rust
mod auth;
|
|
mod registry;
|
|
mod routes;
|
|
|
|
use anyhow::{Context, Result};
|
|
use std::{net::SocketAddr, path::PathBuf};
|
|
|
|
const DEFAULT_BIND: &str = "127.0.0.1:8080";
|
|
|
|
/// Prune abandoned staged-upload UUID dirs under `uploads_dir` whose content
|
|
/// is older than `cutoff`.
|
|
///
|
|
/// `cleanup_stale_sentinels`: pass `true` at startup (before the server begins
|
|
/// accepting connections) so crash-leftover `.uploading` markers are removed and
|
|
/// those dirs are subject to the normal age check. Pass `false` from the
|
|
/// in-process periodic task so dirs with a live sentinel (active XHR) are
|
|
/// skipped entirely.
|
|
///
|
|
/// Staleness is measured from the newest non-sentinel child file's mtime so a
|
|
/// just-finished slow upload is not pruned before the user submits it for
|
|
/// capture. Empty dirs fall back to the directory mtime.
|
|
fn prune_stale_upload_dirs(
|
|
uploads_dir: &std::path::Path,
|
|
cutoff: std::time::SystemTime,
|
|
cleanup_stale_sentinels: bool,
|
|
) {
|
|
let Ok(entries) = std::fs::read_dir(uploads_dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if !path.is_dir() {
|
|
continue;
|
|
}
|
|
let sentinel = path.join(".uploading");
|
|
if sentinel.exists() {
|
|
if cleanup_stale_sentinels {
|
|
// Server just started — no uploads are in flight, so any
|
|
// sentinel is a crash remnant. Remove it and fall through
|
|
// to the age check below.
|
|
let _ = std::fs::remove_file(&sentinel);
|
|
} else {
|
|
// An active XHR is writing to this dir — leave it alone.
|
|
continue;
|
|
}
|
|
}
|
|
// Measure staleness from the newest non-sentinel child file so a
|
|
// completed slow upload isn't pruned while the user is still on the
|
|
// capture form. Fall back to dir mtime only when the dir is empty.
|
|
let newest_child = std::fs::read_dir(&path)
|
|
.ok()
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.file_name() != std::ffi::OsStr::new(".uploading"))
|
|
.filter_map(|e| e.metadata().ok())
|
|
.filter_map(|m| m.modified().ok())
|
|
.max();
|
|
let reference = newest_child
|
|
.or_else(|| entry.metadata().and_then(|m| m.modified()).ok())
|
|
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
|
if reference < cutoff {
|
|
let _ = std::fs::remove_dir_all(&path);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
let config_path = std::env::args()
|
|
.nth(1)
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from("archivr-server.toml"));
|
|
|
|
let registry = registry::load_registry(&config_path)?;
|
|
|
|
// Auth DB lives next to the config file unless overridden in the TOML.
|
|
let auth_db_path = registry.auth_db_path.clone().unwrap_or_else(|| {
|
|
config_path
|
|
.parent()
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.join("archivr-auth.sqlite")
|
|
});
|
|
|
|
let app = routes::app(registry.clone(), auth_db_path.clone());
|
|
|
|
// On startup, mark any jobs that were 'running' when the server last stopped as 'failed'.
|
|
for archive in ®istry.archives {
|
|
if let Ok(conn) = archivr_core::database::open_or_initialize(&archive.archive_path) {
|
|
match archivr_core::database::fail_stalled_capture_jobs(&conn) {
|
|
Ok(n) if n > 0 => eprintln!(
|
|
"info: marked {n} stalled capture job(s) as failed in '{}'",
|
|
archive.id
|
|
),
|
|
Err(e) => eprintln!(
|
|
"warn: stalled job cleanup failed for '{}': {e:#}",
|
|
archive.id
|
|
),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prune staged upload dirs older than 24 h. cleanup_stale_sentinels=true
|
|
// because no uploads are in flight before the server starts listening.
|
|
let prune_cutoff = std::time::SystemTime::now()
|
|
.checked_sub(std::time::Duration::from_secs(24 * 60 * 60))
|
|
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
|
for archive in ®istry.archives {
|
|
if let Ok(paths) = archivr_core::archive::read_archive_paths(&archive.archive_path) {
|
|
let uploads_dir = paths.store_path.join("temp").join("uploads");
|
|
prune_stale_upload_dirs(&uploads_dir, prune_cutoff, true);
|
|
}
|
|
}
|
|
|
|
// Spawn maintenance task: session cleanup + staged-upload pruning, every 24 h.
|
|
let cleanup_auth_path = auth_db_path.clone();
|
|
let cleanup_registry = registry.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
if let Ok(conn) = archivr_core::database::open_auth_db(&cleanup_auth_path) {
|
|
match archivr_core::database::delete_expired_sessions(&conn) {
|
|
Ok(n) if n > 0 => eprintln!("info: cleaned up {n} expired sessions"),
|
|
Err(e) => eprintln!("warn: session cleanup failed: {e:#}"),
|
|
_ => {}
|
|
}
|
|
}
|
|
// cleanup_stale_sentinels=false: server is live, respect active uploads.
|
|
let prune_cutoff = std::time::SystemTime::now()
|
|
.checked_sub(std::time::Duration::from_secs(24 * 60 * 60))
|
|
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
|
for archive in &cleanup_registry.archives {
|
|
if let Ok(paths) = archivr_core::archive::read_archive_paths(&archive.archive_path)
|
|
{
|
|
let uploads_dir = paths.store_path.join("temp").join("uploads");
|
|
prune_stale_upload_dirs(&uploads_dir, prune_cutoff, false);
|
|
}
|
|
}
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(24 * 60 * 60)).await;
|
|
}
|
|
});
|
|
|
|
let bind_str = std::env::var("ARCHIVR_BIND")
|
|
.ok()
|
|
.or_else(|| registry.bind.clone())
|
|
.unwrap_or_else(|| DEFAULT_BIND.to_string());
|
|
|
|
let addr: SocketAddr = bind_str
|
|
.parse()
|
|
.with_context(|| format!("invalid bind address: {bind_str}"))?;
|
|
|
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
|
println!("archivr-server listening on http://{addr}");
|
|
axum::serve(
|
|
listener,
|
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|