mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 21:03:17 +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:
parent
6377daadae
commit
1af920eb63
17 changed files with 1498 additions and 142 deletions
|
|
@ -1866,9 +1866,24 @@ pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result<Arch
|
|||
)?;
|
||||
}
|
||||
|
||||
// Auto-enroll in the default collection with appropriate visibility_bits.
|
||||
// Auto-enroll in the default collection.
|
||||
// Root entries: use the collection's configured default_visibility_bits so the
|
||||
// archive owner's visibility setting is respected — capture always passes
|
||||
// "private" (visibility_to_bits → 0) as a safe fallback regardless of intent.
|
||||
// Child entries (parent_entry_id IS NOT NULL): keep visibility_to_bits(entry.visibility)
|
||||
// so they inherit the private/unlisted bits set by the parent capture path;
|
||||
// list_child_entries_inherits_parent_visibility documents this contract.
|
||||
let default_coll_id = ensure_default_collection(conn)?;
|
||||
let vbits = visibility_to_bits(&entry.visibility);
|
||||
let vbits: u32 = if entry.parent_entry_id.is_none() {
|
||||
conn.query_row(
|
||||
"SELECT default_visibility_bits FROM collections WHERE id = ?1",
|
||||
[default_coll_id],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
visibility_to_bits(&entry.visibility)
|
||||
};
|
||||
add_entry_to_collection(conn, default_coll_id, id, vbits)?;
|
||||
|
||||
Ok(ArchivedEntry {
|
||||
|
|
@ -2311,6 +2326,7 @@ pub fn visibility_to_bits(visibility: &str) -> u32 {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/// Returns the id of the '_default_' collection, creating it if absent.
|
||||
pub fn ensure_default_collection(conn: &Connection) -> Result<i64> {
|
||||
let now = now_timestamp();
|
||||
|
|
@ -2466,7 +2482,8 @@ pub fn get_entry_collection_memberships(
|
|||
|
||||
/// Renames a collection and/or updates its default_visibility_bits.
|
||||
/// Returns true if updated, false if not found.
|
||||
/// Refuses to rename the '_default_' collection.
|
||||
/// Refuses to rename the '_default_' collection but allows changing its
|
||||
/// default_visibility_bits so users can control the visibility of new captures.
|
||||
pub fn update_collection(
|
||||
conn: &Connection,
|
||||
collection_uid: &str,
|
||||
|
|
@ -2475,8 +2492,8 @@ pub fn update_collection(
|
|||
) -> Result<bool> {
|
||||
let coll = get_collection_by_uid(conn, collection_uid)?;
|
||||
let Some(coll) = coll else { return Ok(false) };
|
||||
if coll.slug == "_default_" {
|
||||
anyhow::bail!("cannot modify the default collection");
|
||||
if coll.slug == "_default_" && new_name.is_some() {
|
||||
anyhow::bail!("cannot rename the default collection");
|
||||
}
|
||||
let name = new_name.unwrap_or(&coll.name);
|
||||
let vbits = new_visibility_bits.unwrap_or(coll.default_visibility_bits);
|
||||
|
|
@ -2636,6 +2653,7 @@ pub fn delete_entry_artifacts(conn: &Connection, entry_id: i64) -> Result<usize>
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::archive;
|
||||
use std::{
|
||||
env, fs,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
|
|
@ -2930,6 +2948,121 @@ mod tests {
|
|||
assert_eq!(public_index_entry_count(&conn).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_collection_allows_visibility_change_but_not_rename() {
|
||||
let conn = conn();
|
||||
let coll_uid = {
|
||||
let id = ensure_default_collection(&conn).unwrap();
|
||||
conn.query_row(
|
||||
"SELECT collection_uid FROM collections WHERE id = ?1",
|
||||
[id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Changing default_visibility_bits on _default_ must succeed.
|
||||
let updated = update_collection(&conn, &coll_uid, None, Some(3)).unwrap();
|
||||
assert!(updated, "visibility change on _default_ should succeed");
|
||||
let bits: u32 = conn
|
||||
.query_row(
|
||||
"SELECT default_visibility_bits FROM collections WHERE collection_uid = ?1",
|
||||
[&coll_uid],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bits, 3, "default_visibility_bits should be updated to 3");
|
||||
|
||||
// Renaming _default_ must still be rejected.
|
||||
let err = update_collection(&conn, &coll_uid, Some("My Archive"), None);
|
||||
assert!(err.is_err(), "renaming _default_ must be rejected");
|
||||
assert!(
|
||||
err.unwrap_err().to_string().contains("cannot rename"),
|
||||
"error must mention rename"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_collection_visibility_governs_enrollment_not_entry_visibility() {
|
||||
// Regression: capture hardcodes entry.visibility = "private", but
|
||||
// collection_entries.visibility_bits must come from the collection's
|
||||
// default_visibility_bits so that non-admin users can see new entries.
|
||||
let conn = conn();
|
||||
|
||||
// Confirm the default collection starts with default_visibility_bits = 2
|
||||
// (USER-only / "unlisted").
|
||||
let default_id = ensure_default_collection(&conn).unwrap();
|
||||
let default_bits: u32 = conn
|
||||
.query_row(
|
||||
"SELECT default_visibility_bits FROM collections WHERE id = ?1",
|
||||
[default_id],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(default_bits, 2, "default collection should start USER-visible");
|
||||
|
||||
// Create an entry with visibility = "private" (what capture always passes).
|
||||
let entry = create_entry_fixture(&conn, "private", None, None);
|
||||
|
||||
// The archived_entries row must keep the caller's value ("private").
|
||||
let stored_vis: String = conn
|
||||
.query_row(
|
||||
"SELECT visibility FROM archived_entries WHERE id = ?1",
|
||||
[entry.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored_vis, "private");
|
||||
|
||||
// But the collection_entries enrollment must use default_visibility_bits (2),
|
||||
// not visibility_to_bits("private") (0) — otherwise the entry is invisible.
|
||||
let enrolled_bits: u32 = conn
|
||||
.query_row(
|
||||
"SELECT visibility_bits FROM collection_entries WHERE entry_id = ?1",
|
||||
[entry.id],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
enrolled_bits, 2,
|
||||
"collection_entries.visibility_bits must come from the collection default, not entry.visibility"
|
||||
);
|
||||
|
||||
// A USER caller (role_bits = 2) must now see the entry.
|
||||
let visible = archive::list_root_entries(&conn, 2).unwrap();
|
||||
assert_eq!(visible.len(), 1, "USER should see the entry after fix");
|
||||
|
||||
// An explicit public entry must still enroll at bits = 3 when the
|
||||
// collection default is changed to public.
|
||||
conn.execute(
|
||||
"UPDATE collections SET default_visibility_bits = 3 WHERE id = ?1",
|
||||
[default_id],
|
||||
)
|
||||
.unwrap();
|
||||
let public_entry = create_entry_fixture(&conn, "public", None, None);
|
||||
let public_bits: u32 = conn
|
||||
.query_row(
|
||||
"SELECT visibility_bits FROM collection_entries WHERE entry_id = ?1",
|
||||
[public_entry.id],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(public_bits, 3, "collection default=public should produce bits=3");
|
||||
|
||||
// Child entries must NOT use the collection default — they keep
|
||||
// visibility_to_bits(entry.visibility) so parent-child visibility
|
||||
// inheritance is not broken (list_child_entries_inherits_parent_visibility).
|
||||
let child = create_entry_fixture(&conn, "private", Some(entry.id), Some(entry.id));
|
||||
let child_bits: u32 = conn
|
||||
.query_row(
|
||||
"SELECT visibility_bits FROM collection_entries WHERE entry_id = ?1",
|
||||
[child.id],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(child_bits, 0, "child entries must use visibility_to_bits, not collection default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchical_tag_assignments_are_discoverable_through_ancestors() {
|
||||
let conn = conn();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue