1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 12:55:00 +02:00

feat: share videos to TV (Chromecast + AirPlay) (#33)

* 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.
This commit is contained in:
TheGeneralist 2026-07-23 20:12:22 +02:00 • committed by GitHub
parent 6377daadae
commit 1af920eb63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1498 additions and 142 deletions

View file

@ -7,6 +7,64 @@ 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()
@ -30,15 +88,34 @@ async fn main() -> Result<()> {
for archive in &registry.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),
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
),
_ => {}
}
}
}
// Spawn session cleanup: runs at startup and every 24h.
// 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 &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, 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) {
@ -48,6 +125,17 @@ async fn main() -> Result<()> {
_ => {}
}
}
// 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;
}
});
@ -63,6 +151,10 @@ async fn main() -> Result<()> {
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?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}