mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 21:19:45 +00:00
recordings: download as MP4, converted on request with guacenc (#241)
A .guac recording only plays in rustguac's own player. The Recordings page now has an mp4 button per recording: it starts a conversion, shows progress, and downloads the video when ready. The file is a standard MP4 that plays in VLC and browsers. - POST /api/recordings/:name/mp4 starts it (202 running, 200 ready, 409 while the session is live, 503 without guacenc), GET .../mp4/status reports it, GET .../mp4 downloads it. Same access and name checks as the recording download (poweruser or admin). - guacenc writes <input>.m4v beside its input, so each job links the recording into a work directory inside the cache and moves the finished file to <cache>/<stem>.mp4; the recording directory is never written. guacenc exits 0 even on failure, so success is the output existing and not being empty. Runs with a timeout, killed on timeout. - At most mp4_max_jobs at once (default 1). The video is the recording's own size (its largest layer 0 size), scaled down to fit 1920x1080 and rounded to even. - Cached MP4s go after mp4_cache_hours (24), when their recording goes, and all of them before rotation removes any recording for disk space. Leftover work directories are cleared at startup and hourly. - The listing carries each recording's mp4 state only when conversion is available, so the button is hidden on a build without guacenc. Config: mp4_enabled, guacenc_path, mp4_cache_path, mp4_cache_hours, mp4_max_jobs, mp4_timeout_secs, mp4_bitrate, mp4_max_width/height under [recording]. Docs in configuration.md (MP4 downloads, including converting by hand) and api.md, which also had the recordings role as operator; it is poweruser. Tests: the converter (cache layout, failure leaves nothing, live and missing refused, purge, size parsing and fitting) with a stand-in guacenc, the shared access check, and a browser spec for the button.
This commit is contained in:
+14
-2
@@ -402,11 +402,23 @@ To build your own client, open the WebSocket at `ws_url` with the `guacamole` su
|
||||
|
||||
### `GET /api/recordings`
|
||||
|
||||
List all recording files. Requires **operator** role or higher.
|
||||
List all recording files. Requires **poweruser** role or higher. When MP4 conversion is available on the server, each recording has an `mp4` field with the state of its MP4 (see below); without it, the field is left out.
|
||||
|
||||
### `GET /api/recordings/:name`
|
||||
|
||||
Serve a recording file for playback. Requires **operator** role or higher. Filename is validated against path traversal.
|
||||
Serve a recording file for playback. Requires **poweruser** role or higher. Filename is validated against path traversal.
|
||||
|
||||
### `POST /api/recordings/:name/mp4`
|
||||
|
||||
Start converting a recording to MP4 with guacenc (see [MP4 downloads](configuration.md#mp4-downloads)). Same access as downloading the recording. Responses: `202` `{"state":"running"}` while it converts (asking again while it runs changes nothing); `200` `{"state":"ready","size_bytes":...}` when a converted copy is already there; `409` while the recording's session is still live; `404` for an unknown recording; `503` when conversion is not available on this server.
|
||||
|
||||
### `GET /api/recordings/:name/mp4/status`
|
||||
|
||||
Where a recording's MP4 stands: `{"state": "none" | "running" | "ready" | "failed" | "unavailable"}`, with `size_bytes` when ready and `error` when failed. A failed conversion is retried by the next `POST`.
|
||||
|
||||
### `GET /api/recordings/:name/mp4`
|
||||
|
||||
Download the converted MP4 (`video/mp4`, saved as `<recording>.mp4`). `404` until it is ready.
|
||||
|
||||
### `DELETE /api/recordings/:name`
|
||||
|
||||
|
||||
@@ -228,6 +228,14 @@ Controls session recording behaviour and disk management.
|
||||
| `typescript_path` | string | (unset) | Directory for SSH typescript (raw terminal text) files. Unset = disabled. See below. |
|
||||
| `typescript_name` | string | `{connection}-{user}-{date}-{time}` | Filename template for typescripts. Tokens listed below. |
|
||||
| `create_typescript_path` | bool | `false` | Ask guacd to create `typescript_path` if it does not exist. |
|
||||
| `mp4_enabled` | bool | `true` | Offer recordings as MP4 downloads (see below). Off by itself when guacenc is not installed. |
|
||||
| `guacenc_path` | string | `/opt/rustguac/bin/guacenc` | The guacenc binary that converts recordings. |
|
||||
| `mp4_cache_path` | string | `<path>/mp4-cache` | Where converted MP4s are kept. |
|
||||
| `mp4_cache_hours` | integer | `24` | Remove a converted MP4 this many hours after it was made. |
|
||||
| `mp4_max_jobs` | integer | `1` | Conversions that run at once; more wait their turn. |
|
||||
| `mp4_timeout_secs` | integer | `1800` | Give up on a conversion after this many seconds. |
|
||||
| `mp4_bitrate` | integer | `2000000` | Video bitrate, in bits per second. |
|
||||
| `mp4_max_width`, `mp4_max_height` | integer | `1920`, `1080` | Largest video size. Bigger recordings are scaled down to fit, keeping their shape. |
|
||||
|
||||
```toml
|
||||
[recording]
|
||||
@@ -305,6 +313,42 @@ that depends on guacd-driven graphical recording, which rustguac does not
|
||||
use (it records the proxied stream itself). It is therefore not wired up;
|
||||
the typescript is the supported text-audit path.
|
||||
|
||||
### MP4 downloads
|
||||
|
||||
A `.guac` recording only plays in rustguac's own player. The Recordings page
|
||||
can also give you an MP4 that plays anywhere (VLC, a browser, a video
|
||||
editor): click **mp4** next to a recording. rustguac converts it in the
|
||||
background with `guacenc`, which comes with guacamole-server, and the file
|
||||
downloads when it is ready. Converting a long session takes a while, so the
|
||||
button shows **converting...** until then.
|
||||
|
||||
- The same people can get the MP4 as can download the recording (poweruser
|
||||
and admin).
|
||||
- A recording whose session is still running is not converted, since the
|
||||
file is not finished.
|
||||
- Converted files are kept in `mp4_cache_path` for `mp4_cache_hours`, so a
|
||||
second download is instant, and removed when their recording is. When
|
||||
rotation needs disk space it removes them all before touching any
|
||||
recording.
|
||||
- The video is the recording's own size, scaled down to fit
|
||||
`mp4_max_width` x `mp4_max_height`.
|
||||
- The button only appears when guacenc is installed. It is part of the
|
||||
`.deb`, the Docker image and `install.sh` from 1.11.1; on a build without
|
||||
it the button is not shown.
|
||||
|
||||
To convert recordings yourself, for example a batch for an audit, run
|
||||
guacenc on the rustguac host. It writes `<recording>.m4v` (an MP4 file)
|
||||
next to the input, so copy recordings somewhere writable first:
|
||||
|
||||
```bash
|
||||
cp /opt/rustguac/recordings/<id>.guac /tmp/
|
||||
/opt/rustguac/bin/guacenc -s 1920x1080 -r 2000000 /tmp/<id>.guac
|
||||
# -> /tmp/<id>.guac.m4v
|
||||
```
|
||||
|
||||
guacenc reports failures in its output but exits 0 either way, so check
|
||||
that the `.m4v` file exists.
|
||||
|
||||
#### Encryption at rest (LUKS)
|
||||
|
||||
Typescripts are written in plain text. For encryption at rest with no extra
|
||||
|
||||
+189
@@ -911,6 +911,7 @@ pub struct CredentialDefaultScope(pub String);
|
||||
pub async fn list_recordings(
|
||||
State(manager): State<AppState>,
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
mp4: Option<Extension<Arc<crate::recording_mp4::Mp4Converter>>>,
|
||||
) -> impl IntoResponse {
|
||||
match &identity {
|
||||
Some(Extension(id)) if id.has_role("poweruser") => {}
|
||||
@@ -923,6 +924,9 @@ pub async fn list_recordings(
|
||||
}
|
||||
}
|
||||
let recording_path = manager.recording_path().to_path_buf();
|
||||
// Each recording's MP4 state (#241), when conversion is available here;
|
||||
// the field is left out otherwise, which is how the page knows.
|
||||
let mp4 = mp4.map(|Extension(c)| c).filter(|c| c.enabled());
|
||||
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
let mut recordings = Vec::new();
|
||||
@@ -975,6 +979,9 @@ pub async fn list_recordings(
|
||||
rec["session_type"] = json!(t);
|
||||
}
|
||||
}
|
||||
if let Some(ref conv) = mp4 {
|
||||
rec["mp4"] = mp4_state_json(&conv.state(&name))["state"].clone();
|
||||
}
|
||||
recordings.push(rec);
|
||||
}
|
||||
recordings.sort_by(|a, b| {
|
||||
@@ -1482,6 +1489,144 @@ pub async fn serve_recording(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// The check every recording read makes (download, MP4): poweruser or
|
||||
/// admin, and a plain `.guac` name inside the recording directory.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn check_recording_read(
|
||||
identity: &Option<Extension<AuthIdentity>>,
|
||||
name: &str,
|
||||
recording_dir: &std::path::Path,
|
||||
) -> Result<(), Response> {
|
||||
match identity {
|
||||
Some(Extension(id)) if id.has_role("poweruser") => {}
|
||||
_ => {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({"error": "requires poweruser or admin role"})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
if !is_safe_recording_name(name, recording_dir) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "invalid recording name"})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mp4_state_json(state: &crate::recording_mp4::Mp4State) -> serde_json::Value {
|
||||
use crate::recording_mp4::Mp4State;
|
||||
match state {
|
||||
Mp4State::Unavailable => json!({"state": "unavailable"}),
|
||||
Mp4State::None => json!({"state": "none"}),
|
||||
Mp4State::Running => json!({"state": "running"}),
|
||||
Mp4State::Ready { size_bytes } => json!({"state": "ready", "size_bytes": size_bytes}),
|
||||
Mp4State::Failed(why) => json!({"state": "failed", "error": why}),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/recordings/{name}/mp4: Start converting a recording to MP4
|
||||
/// (#241). Same access as downloading the recording. 202 while it converts,
|
||||
/// 200 when a converted copy is already there, 409 while the recording's
|
||||
/// session is still live, 503 when conversion is not available here.
|
||||
pub async fn start_recording_mp4(
|
||||
State(manager): State<AppState>,
|
||||
Extension(mp4): Extension<Arc<crate::recording_mp4::Mp4Converter>>,
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
Path(name): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
use crate::recording_mp4::StartOutcome;
|
||||
if let Err(resp) = check_recording_read(&identity, &name, manager.recording_path()) {
|
||||
return resp;
|
||||
}
|
||||
let who = identity
|
||||
.as_ref()
|
||||
.map(|Extension(id)| id.display_name().to_string())
|
||||
.unwrap_or_default();
|
||||
let live = manager
|
||||
.active_recording_paths()
|
||||
.await
|
||||
.contains(&manager.recording_path().join(&name));
|
||||
match mp4.start(&name, live, &who) {
|
||||
StartOutcome::Started | StartOutcome::AlreadyRunning => {
|
||||
(StatusCode::ACCEPTED, Json(json!({"state": "running"}))).into_response()
|
||||
}
|
||||
StartOutcome::AlreadyReady { size_bytes } => {
|
||||
Json(json!({"state": "ready", "size_bytes": size_bytes})).into_response()
|
||||
}
|
||||
StartOutcome::Live => (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "this session is still being recorded; try again once it has ended"})),
|
||||
)
|
||||
.into_response(),
|
||||
StartOutcome::NotFound => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "recording not found"})),
|
||||
)
|
||||
.into_response(),
|
||||
StartOutcome::Unavailable => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({"error": "MP4 conversion is not available on this server"})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/recordings/{name}/mp4/status: Where a recording's MP4 stands:
|
||||
/// `none`, `running`, `ready` (with `size_bytes`), `failed` (with `error`)
|
||||
/// or `unavailable`.
|
||||
pub async fn recording_mp4_status(
|
||||
State(manager): State<AppState>,
|
||||
Extension(mp4): Extension<Arc<crate::recording_mp4::Mp4Converter>>,
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
Path(name): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(resp) = check_recording_read(&identity, &name, manager.recording_path()) {
|
||||
return resp;
|
||||
}
|
||||
Json(mp4_state_json(&mp4.state(&name))).into_response()
|
||||
}
|
||||
|
||||
/// GET /api/recordings/{name}/mp4: Download the converted MP4, once ready.
|
||||
pub async fn serve_recording_mp4(
|
||||
State(manager): State<AppState>,
|
||||
Extension(mp4): Extension<Arc<crate::recording_mp4::Mp4Converter>>,
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
Path(name): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
use crate::recording_mp4::Mp4State;
|
||||
if let Err(resp) = check_recording_read(&identity, &name, manager.recording_path()) {
|
||||
return resp;
|
||||
}
|
||||
let not_yet = || {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "no MP4 yet; start one with POST"})),
|
||||
)
|
||||
.into_response()
|
||||
};
|
||||
if !matches!(mp4.state(&name), Mp4State::Ready { .. }) {
|
||||
return not_yet();
|
||||
}
|
||||
let file = match tokio::fs::File::open(mp4.mp4_path(&name)).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => return not_yet(),
|
||||
};
|
||||
let stem = name.strip_suffix(".guac").unwrap_or(&name);
|
||||
axum::response::Response::builder()
|
||||
.header("content-type", "video/mp4")
|
||||
.header(
|
||||
"content-disposition",
|
||||
format!("attachment; filename=\"{}.mp4\"", stem),
|
||||
)
|
||||
.body(Body::from_stream(ReaderStream::new(file)))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// DELETE /api/recordings/{name} — Delete a recording file. Requires admin.
|
||||
pub async fn delete_recording(
|
||||
State(manager): State<AppState>,
|
||||
@@ -8686,6 +8831,50 @@ mod tests {
|
||||
assert!(!is_safe_recording_name("..guac", Path::new("/tmp")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_mp4_access_matches_recording_download() {
|
||||
// The MP4 endpoints (#241) share the download's check: poweruser or
|
||||
// admin, and a plain .guac name inside the recording directory.
|
||||
let dir = std::env::temp_dir().join(format!("rustguac-rec-acl-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("s.guac"), "x").unwrap();
|
||||
let user = |role: &str| {
|
||||
Some(Extension(AuthIdentity::User {
|
||||
email: "u@example.com".into(),
|
||||
role: role.into(),
|
||||
groups: vec![],
|
||||
}))
|
||||
};
|
||||
let status = |id: &Option<Extension<AuthIdentity>>, name: &str| {
|
||||
check_recording_read(id, name, &dir)
|
||||
.err()
|
||||
.map(|r| r.status())
|
||||
};
|
||||
assert_eq!(status(&user("poweruser"), "s.guac"), None);
|
||||
assert_eq!(status(&user("admin"), "s.guac"), None);
|
||||
assert_eq!(
|
||||
status(&Some(Extension(AuthIdentity::ApiKey("k".into()))), "s.guac"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
status(&user("operator"), "s.guac"),
|
||||
Some(StatusCode::FORBIDDEN)
|
||||
);
|
||||
assert_eq!(
|
||||
status(&user("viewer"), "s.guac"),
|
||||
Some(StatusCode::FORBIDDEN)
|
||||
);
|
||||
assert_eq!(status(&None, "s.guac"), Some(StatusCode::FORBIDDEN));
|
||||
for bad in ["../s.guac", "a/s.guac", "s.mp4", "..guac", "s.guac\0"] {
|
||||
assert_eq!(
|
||||
status(&user("admin"), bad),
|
||||
Some(StatusCode::BAD_REQUEST),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_recording_name_extension_case_sensitive() {
|
||||
// Rustguac writes lower-case .guac only; allowing .GUAC creates a
|
||||
|
||||
@@ -227,6 +227,63 @@ pub struct RecordingConfig {
|
||||
/// Ask guacd to create `typescript_path` if it doesn't already exist.
|
||||
#[serde(default)]
|
||||
pub create_typescript_path: bool,
|
||||
/// Offer recordings as MP4 downloads, converted on request with guacenc
|
||||
/// (#241). Default: true. Has no effect when guacenc is not installed.
|
||||
#[serde(default = "default_true")]
|
||||
pub mp4_enabled: bool,
|
||||
/// The guacenc binary. Default: /opt/rustguac/bin/guacenc.
|
||||
#[serde(default = "default_guacenc_path")]
|
||||
pub guacenc_path: PathBuf,
|
||||
/// Where converted MP4s are kept. Default: `mp4-cache` inside the
|
||||
/// recording directory. Emptied first when rotation needs disk space.
|
||||
#[serde(default)]
|
||||
pub mp4_cache_path: Option<PathBuf>,
|
||||
/// Remove a converted MP4 this many hours after it was made. Default: 24.
|
||||
#[serde(default = "default_mp4_cache_hours")]
|
||||
pub mp4_cache_hours: u64,
|
||||
/// Conversions run at once; more wait their turn. Default: 1.
|
||||
#[serde(default = "default_mp4_max_jobs")]
|
||||
pub mp4_max_jobs: u32,
|
||||
/// Give up on a conversion after this many seconds. Default: 1800.
|
||||
#[serde(default = "default_mp4_timeout_secs")]
|
||||
pub mp4_timeout_secs: u64,
|
||||
/// Video bitrate in bits per second. Default: 2000000.
|
||||
#[serde(default = "default_mp4_bitrate")]
|
||||
pub mp4_bitrate: u32,
|
||||
/// Largest video size; bigger recordings are scaled down to fit, keeping
|
||||
/// their shape. Default: 1920 x 1080.
|
||||
#[serde(default = "default_mp4_max_width")]
|
||||
pub mp4_max_width: u32,
|
||||
#[serde(default = "default_mp4_max_height")]
|
||||
pub mp4_max_height: u32,
|
||||
}
|
||||
|
||||
fn default_guacenc_path() -> PathBuf {
|
||||
PathBuf::from("/opt/rustguac/bin/guacenc")
|
||||
}
|
||||
|
||||
fn default_mp4_cache_hours() -> u64 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_mp4_max_jobs() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_mp4_timeout_secs() -> u64 {
|
||||
1800
|
||||
}
|
||||
|
||||
fn default_mp4_bitrate() -> u32 {
|
||||
2_000_000
|
||||
}
|
||||
|
||||
fn default_mp4_max_width() -> u32 {
|
||||
1920
|
||||
}
|
||||
|
||||
fn default_mp4_max_height() -> u32 {
|
||||
1080
|
||||
}
|
||||
|
||||
fn default_max_disk_percent() -> u8 {
|
||||
@@ -248,6 +305,15 @@ impl Default for RecordingConfig {
|
||||
typescript_path: None,
|
||||
typescript_name: None,
|
||||
create_typescript_path: false,
|
||||
mp4_enabled: true,
|
||||
guacenc_path: default_guacenc_path(),
|
||||
mp4_cache_path: None,
|
||||
mp4_cache_hours: default_mp4_cache_hours(),
|
||||
mp4_max_jobs: default_mp4_max_jobs(),
|
||||
mp4_timeout_secs: default_mp4_timeout_secs(),
|
||||
mp4_bitrate: default_mp4_bitrate(),
|
||||
mp4_max_width: default_mp4_max_width(),
|
||||
mp4_max_height: default_mp4_max_height(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
-3
@@ -11,6 +11,7 @@ mod oidc;
|
||||
mod protocol;
|
||||
mod pve;
|
||||
mod recording;
|
||||
mod recording_mp4;
|
||||
mod session;
|
||||
mod tunnel;
|
||||
mod vault;
|
||||
@@ -1236,6 +1237,25 @@ async fn run_server(config: Config, database: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
// MP4 downloads of recordings (#241): guacenc on request, cached.
|
||||
let mp4_converter = Arc::new(recording_mp4::Mp4Converter::new(
|
||||
&manager.recording_config(),
|
||||
manager.recording_path(),
|
||||
));
|
||||
{
|
||||
// Clear out what an earlier run left (work directories, expired and
|
||||
// orphaned MP4s) now, then every hour.
|
||||
let conv = mp4_converter.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let c = conv.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || c.purge(false)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn recording rotation background task
|
||||
{
|
||||
let rec_config = manager.recording_config();
|
||||
@@ -1248,6 +1268,7 @@ async fn run_server(config: Config, database: Db) {
|
||||
interval_secs
|
||||
);
|
||||
let rot_manager = manager.clone();
|
||||
let rot_conv = mp4_converter.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(interval_secs));
|
||||
@@ -1258,8 +1279,19 @@ async fn run_server(config: Config, database: Db) {
|
||||
// Snapshot recordings of still-live sessions so rotation
|
||||
// never deletes an in-progress (open) recording.
|
||||
let active = rot_manager.active_recording_paths().await;
|
||||
let _ =
|
||||
tokio::task::spawn_blocking(move || recording::rotate(&cfg, &active)).await;
|
||||
let conv = rot_conv.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
// Converted MP4s are copies, so they go before any
|
||||
// recording does when the disk is too full.
|
||||
if cfg.max_disk_percent > 0
|
||||
&& recording::disk_usage_percent(&cfg.path)
|
||||
.is_ok_and(|p| p > cfg.max_disk_percent as f64)
|
||||
{
|
||||
conv.purge(true);
|
||||
}
|
||||
recording::rotate(&cfg, &active)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1313,6 +1345,14 @@ async fn run_server(config: Config, database: Db) {
|
||||
.route("/api/recordings", get(api::list_recordings))
|
||||
.route("/api/recordings/{name}", get(api::serve_recording))
|
||||
.route("/api/recordings/{name}", delete(api::delete_recording))
|
||||
.route(
|
||||
"/api/recordings/{name}/mp4",
|
||||
post(api::start_recording_mp4).get(api::serve_recording_mp4),
|
||||
)
|
||||
.route(
|
||||
"/api/recordings/{name}/mp4/status",
|
||||
get(api::recording_mp4_status),
|
||||
)
|
||||
// #159: typescript register is list-only by design (no serve/delete).
|
||||
.route("/api/typescripts", get(api::list_typescripts))
|
||||
.route("/api/reports/sessions", get(api::report_sessions))
|
||||
@@ -1455,7 +1495,8 @@ async fn run_server(config: Config, database: Db) {
|
||||
.layer(Extension(vault_configured.clone()))
|
||||
.layer(Extension(credential_default_scope.clone()))
|
||||
.layer(Extension(database.clone()))
|
||||
.layer(Extension(ab_changes.clone()));
|
||||
.layer(Extension(ab_changes.clone()))
|
||||
.layer(Extension(mp4_converter.clone()));
|
||||
// Applied OUTSIDE require_auth so requests with bad credentials are
|
||||
// counted too: a flood of bogus keys is throttled before each one
|
||||
// queues on the database lock to be looked up.
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
//! MP4 downloads of recordings (#241).
|
||||
//!
|
||||
//! A `.guac` recording only plays inside rustguac. guacenc, which comes with
|
||||
//! guacamole-server, turns one into a video, so a recording can be offered as
|
||||
//! an MP4 that plays anywhere. Conversion takes a while on a long session, so
|
||||
//! it runs in the background on request and the result is cached:
|
||||
//!
|
||||
//! - guacenc writes `<input>.m4v` next to its input. The recording directory
|
||||
//! is left alone: each job links the recording into a private work
|
||||
//! directory inside the cache and converts the link there, then moves the
|
||||
//! finished file to `<cache>/<recording stem>.mp4`. A file at that name is
|
||||
//! always complete; a failed or abandoned job leaves only its work
|
||||
//! directory, which is removed.
|
||||
//! - guacenc exits 0 even when it fails, so success is the output file
|
||||
//! existing and not being empty.
|
||||
//! - A recording still being written (its session is live) is not converted.
|
||||
//! - At most `mp4_max_jobs` conversions run at once; the rest wait.
|
||||
//! - Cached MP4s go after `mp4_cache_hours`, when their recording goes, and
|
||||
//! all at once when rotation needs disk space.
|
||||
|
||||
use crate::config::RecordingConfig;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Where a recording stands with respect to its MP4.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Mp4State {
|
||||
/// Conversion is turned off or guacenc is not installed.
|
||||
Unavailable,
|
||||
/// Not converted, or the conversion has gone from the cache.
|
||||
None,
|
||||
/// Being converted, or waiting for a free slot.
|
||||
Running,
|
||||
/// Converted: the MP4 is ready to download.
|
||||
Ready { size_bytes: u64 },
|
||||
/// The last attempt failed; asking again retries it.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// What happened when a conversion was asked for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StartOutcome {
|
||||
Started,
|
||||
AlreadyRunning,
|
||||
AlreadyReady {
|
||||
size_bytes: u64,
|
||||
},
|
||||
Unavailable,
|
||||
/// The recording's session is still live, so the file is incomplete.
|
||||
Live,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
pub struct Mp4Converter {
|
||||
enabled: bool,
|
||||
guacenc: PathBuf,
|
||||
recording_dir: PathBuf,
|
||||
cache_dir: PathBuf,
|
||||
cache_max_age: Duration,
|
||||
timeout: Duration,
|
||||
bitrate: u32,
|
||||
max_size: (u32, u32),
|
||||
slots: Arc<Semaphore>,
|
||||
/// Jobs not yet finished, and the reason for the last failure, by
|
||||
/// recording name. Absent: nothing in progress (look at the cache).
|
||||
jobs: Mutex<HashMap<String, Job>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Job {
|
||||
Running,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Mp4Converter {
|
||||
pub fn new(cfg: &RecordingConfig, recording_dir: &Path) -> Self {
|
||||
let cache_dir = cfg
|
||||
.mp4_cache_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| recording_dir.join("mp4-cache"));
|
||||
let installed = is_executable(&cfg.guacenc_path);
|
||||
if cfg.mp4_enabled && !installed {
|
||||
tracing::info!(
|
||||
path = %cfg.guacenc_path.display(),
|
||||
"guacenc not found, so MP4 downloads of recordings are off"
|
||||
);
|
||||
}
|
||||
Self {
|
||||
enabled: cfg.mp4_enabled && installed,
|
||||
guacenc: cfg.guacenc_path.clone(),
|
||||
recording_dir: recording_dir.to_path_buf(),
|
||||
cache_dir,
|
||||
cache_max_age: Duration::from_secs(cfg.mp4_cache_hours.max(1) * 3600),
|
||||
timeout: Duration::from_secs(cfg.mp4_timeout_secs.max(10)),
|
||||
bitrate: cfg.mp4_bitrate.max(100_000),
|
||||
max_size: (cfg.mp4_max_width.max(16), cfg.mp4_max_height.max(16)),
|
||||
slots: Arc::new(Semaphore::new(cfg.mp4_max_jobs.max(1) as usize)),
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// The finished MP4 for a recording, once converted. Only ever a
|
||||
/// complete file: jobs move their output here when done.
|
||||
pub fn mp4_path(&self, name: &str) -> PathBuf {
|
||||
let stem = name.strip_suffix(".guac").unwrap_or(name);
|
||||
self.cache_dir.join(format!("{stem}.mp4"))
|
||||
}
|
||||
|
||||
/// The cached MP4's size, if it exists and is at least as new as its
|
||||
/// recording.
|
||||
fn ready_size(&self, name: &str) -> Option<u64> {
|
||||
let mp4 = std::fs::metadata(self.mp4_path(name)).ok()?;
|
||||
let guac = std::fs::metadata(self.recording_dir.join(name)).ok()?;
|
||||
let fresh = match (mp4.modified(), guac.modified()) {
|
||||
(Ok(m), Ok(g)) => m >= g,
|
||||
_ => false,
|
||||
};
|
||||
(fresh && mp4.len() > 0).then_some(mp4.len())
|
||||
}
|
||||
|
||||
pub fn state(&self, name: &str) -> Mp4State {
|
||||
if !self.enabled {
|
||||
return Mp4State::Unavailable;
|
||||
}
|
||||
match self.jobs.lock().unwrap().get(name) {
|
||||
Some(Job::Running) => return Mp4State::Running,
|
||||
Some(Job::Failed(why)) => return Mp4State::Failed(why.clone()),
|
||||
None => {}
|
||||
}
|
||||
match self.ready_size(name) {
|
||||
Some(size_bytes) => Mp4State::Ready { size_bytes },
|
||||
None => Mp4State::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start converting `name` unless it is converted, being converted, live
|
||||
/// or missing. `live` is whether its session is still recording.
|
||||
pub fn start(self: &Arc<Self>, name: &str, live: bool, who: &str) -> StartOutcome {
|
||||
if !self.enabled {
|
||||
return StartOutcome::Unavailable;
|
||||
}
|
||||
let guac = self.recording_dir.join(name);
|
||||
if !guac.is_file() {
|
||||
return StartOutcome::NotFound;
|
||||
}
|
||||
if live {
|
||||
return StartOutcome::Live;
|
||||
}
|
||||
{
|
||||
let mut jobs = self.jobs.lock().unwrap();
|
||||
if matches!(jobs.get(name), Some(Job::Running)) {
|
||||
return StartOutcome::AlreadyRunning;
|
||||
}
|
||||
if let Some(size_bytes) = self.ready_size(name) {
|
||||
jobs.remove(name);
|
||||
return StartOutcome::AlreadyReady { size_bytes };
|
||||
}
|
||||
jobs.insert(name.to_string(), Job::Running);
|
||||
}
|
||||
tracing::info!(user = %who, recording = %name, "MP4 conversion requested");
|
||||
let this = Arc::clone(self);
|
||||
let name = name.to_string();
|
||||
tokio::spawn(async move {
|
||||
let result = this.convert(&name, &guac).await;
|
||||
let mut jobs = this.jobs.lock().unwrap();
|
||||
match result {
|
||||
Ok(()) => {
|
||||
tracing::info!(recording = %name, "MP4 conversion finished");
|
||||
jobs.remove(&name);
|
||||
}
|
||||
Err(why) => {
|
||||
tracing::warn!(recording = %name, reason = %why, "MP4 conversion failed");
|
||||
jobs.insert(name, Job::Failed(why));
|
||||
}
|
||||
}
|
||||
});
|
||||
StartOutcome::Started
|
||||
}
|
||||
|
||||
async fn convert(&self, name: &str, guac: &Path) -> Result<(), String> {
|
||||
let _slot = self
|
||||
.slots
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| "converter shut down".to_string())?;
|
||||
tokio::fs::create_dir_all(&self.cache_dir)
|
||||
.await
|
||||
.map_err(|e| format!("cannot create the MP4 cache: {e}"))?;
|
||||
let work = self
|
||||
.cache_dir
|
||||
.join(format!(".work-{}", uuid::Uuid::new_v4()));
|
||||
let result = self.convert_in(name, guac, &work).await;
|
||||
let _ = tokio::fs::remove_dir_all(&work).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn convert_in(&self, name: &str, guac: &Path, work: &Path) -> Result<(), String> {
|
||||
tokio::fs::create_dir(work)
|
||||
.await
|
||||
.map_err(|e| format!("cannot create a work directory: {e}"))?;
|
||||
let source = tokio::fs::canonicalize(guac)
|
||||
.await
|
||||
.map_err(|_| "the recording has gone".to_string())?;
|
||||
let link = work.join(name);
|
||||
tokio::fs::symlink(&source, &link)
|
||||
.await
|
||||
.map_err(|e| format!("cannot link the recording: {e}"))?;
|
||||
|
||||
let size = {
|
||||
let source = source.clone();
|
||||
tokio::task::spawn_blocking(move || recording_size(&source))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
};
|
||||
let (w, h) = fit_size(size, self.max_size);
|
||||
|
||||
let mut child = tokio::process::Command::new(&self.guacenc)
|
||||
.arg("-s")
|
||||
.arg(format!("{w}x{h}"))
|
||||
.arg("-r")
|
||||
.arg(self.bitrate.to_string())
|
||||
.arg(&link)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| format!("cannot run guacenc: {e}"))?;
|
||||
match tokio::time::timeout(self.timeout, child.wait()).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => return Err(format!("guacenc did not finish: {e}")),
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
return Err(format!(
|
||||
"took longer than {} seconds",
|
||||
self.timeout.as_secs()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// guacenc exits 0 whatever happened: the output is the verdict.
|
||||
let produced = work.join(format!("{name}.m4v"));
|
||||
match tokio::fs::metadata(&produced).await {
|
||||
Ok(m) if m.len() > 0 => {}
|
||||
_ => return Err("guacenc produced no video".to_string()),
|
||||
}
|
||||
tokio::fs::rename(&produced, self.mp4_path(name))
|
||||
.await
|
||||
.map_err(|e| format!("cannot store the MP4: {e}"))
|
||||
}
|
||||
|
||||
/// Remove cached MP4s older than `mp4_cache_hours` or whose recording
|
||||
/// has gone (all of them when `everything`), and work directories of
|
||||
/// jobs no longer running. Returns how many files went.
|
||||
pub fn purge(&self, everything: bool) -> usize {
|
||||
let entries = match std::fs::read_dir(&self.cache_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let running = self
|
||||
.jobs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.any(|j| matches!(j, Job::Running));
|
||||
let now = SystemTime::now();
|
||||
let mut removed = 0;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
if file_name.starts_with(".work-") {
|
||||
// A work directory belongs to a running job, or to one that
|
||||
// died with the process.
|
||||
if !running && std::fs::remove_dir_all(&path).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = file_name.strip_suffix(".mp4") else {
|
||||
continue;
|
||||
};
|
||||
let orphaned = !self.recording_dir.join(format!("{stem}.guac")).exists();
|
||||
let expired = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| now.duration_since(t).ok())
|
||||
.is_some_and(|age| age > self.cache_max_age);
|
||||
if (everything || orphaned || expired) && std::fs::remove_file(&path).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
}
|
||||
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The size of a recording's display: the largest size given to the default
|
||||
/// layer (layer 0) in its first few megabytes, which is where the session
|
||||
/// sets it. `None` when none is found.
|
||||
pub fn recording_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut head = Vec::new();
|
||||
std::fs::File::open(path)
|
||||
.ok()?
|
||||
.take(4 * 1024 * 1024)
|
||||
.read_to_end(&mut head)
|
||||
.ok()?;
|
||||
largest_layer0_size(&String::from_utf8_lossy(&head))
|
||||
}
|
||||
|
||||
/// Walk Guacamole instructions (`LEN.VALUE,LEN.VALUE;`, lengths counted in
|
||||
/// characters) and return the largest `size` of layer 0. Stops at the first
|
||||
/// malformed or truncated element.
|
||||
fn largest_layer0_size(text: &str) -> Option<(u32, u32)> {
|
||||
let mut chars = text.chars().peekable();
|
||||
let mut best: Option<(u32, u32)> = None;
|
||||
let mut instruction: Vec<String> = Vec::new();
|
||||
loop {
|
||||
let mut len = String::new();
|
||||
while let Some(c) = chars.peek() {
|
||||
if c.is_ascii_digit() {
|
||||
len.push(*c);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if len.is_empty() || chars.next() != Some('.') {
|
||||
return best;
|
||||
}
|
||||
let n: usize = len.parse().ok()?;
|
||||
let value: String = chars.by_ref().take(n).collect();
|
||||
if value.chars().count() != n {
|
||||
return best;
|
||||
}
|
||||
instruction.push(value);
|
||||
match chars.next() {
|
||||
Some(',') => {}
|
||||
Some(';') => {
|
||||
if instruction.len() == 4 && instruction[0] == "size" && instruction[1] == "0" {
|
||||
if let (Ok(w), Ok(h)) = (instruction[2].parse(), instruction[3].parse()) {
|
||||
let area = |(w, h): (u32, u32)| u64::from(w) * u64::from(h);
|
||||
if best.is_none_or(|b| area((w, h)) > area(b)) {
|
||||
best = Some((w, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
instruction.clear();
|
||||
}
|
||||
_ => return best,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The video size for a recording: its own size, scaled down to fit within
|
||||
/// `max` keeping its shape, rounded down to even numbers (the encoder needs
|
||||
/// them). 1280 x 720 when the recording's size is unknown.
|
||||
pub fn fit_size(size: Option<(u32, u32)>, max: (u32, u32)) -> (u32, u32) {
|
||||
let (w, h) = match size {
|
||||
Some((w, h)) if w > 0 && h > 0 => (w, h),
|
||||
_ => (1280, 720),
|
||||
};
|
||||
let scale = (max.0 as f64 / w as f64)
|
||||
.min(max.1 as f64 / h as f64)
|
||||
.min(1.0);
|
||||
let even = |v: f64| ((v.floor() as u32) & !1).max(2);
|
||||
(even(w as f64 * scale), even(h as f64 * scale))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config(dir: &Path, guacenc: &Path) -> RecordingConfig {
|
||||
RecordingConfig {
|
||||
path: dir.to_path_buf(),
|
||||
guacenc_path: guacenc.to_path_buf(),
|
||||
..RecordingConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let p =
|
||||
std::env::temp_dir().join(format!("rustguac-mp4-{}-{}", name, std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Scratch(p)
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A stand-in guacenc: a shell script that behaves like the real one
|
||||
/// (writes `<input>.m4v` next to its input and exits 0), or one that
|
||||
/// writes nothing, as guacenc does when it fails.
|
||||
fn fake_guacenc(dir: &Path, works: bool) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let p = dir.join(if works { "guacenc-ok" } else { "guacenc-bad" });
|
||||
let body = if works {
|
||||
"#!/bin/sh\nfor a; do last=\"$a\"; done\nprintf video > \"$last.m4v\"\nexit 0\n"
|
||||
} else {
|
||||
"#!/bin/sh\nexit 0\n"
|
||||
};
|
||||
std::fs::write(&p, body).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
async fn settle(conv: &Arc<Mp4Converter>, name: &str) -> Mp4State {
|
||||
for _ in 0..200 {
|
||||
let s = conv.state(name);
|
||||
if s != Mp4State::Running {
|
||||
return s;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
panic!("conversion never finished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_recordings_own_size() {
|
||||
let rec = "4.size,2.-6,2.41,2.39;4.size,1.0,4.1280,3.720;4.size,1.1,2.10,2.10;\
|
||||
4.size,1.0,4.2556,4.1278;3.png,1.0,2.ab;";
|
||||
assert_eq!(largest_layer0_size(rec), Some((2556, 1278)));
|
||||
// Lengths count characters, not bytes.
|
||||
assert_eq!(
|
||||
largest_layer0_size("4.name,3.é?é;4.size,1.0,3.800,3.600;"),
|
||||
Some((800, 600))
|
||||
);
|
||||
assert_eq!(largest_layer0_size("4.sync,1.0;"), None);
|
||||
// A truncated tail keeps what was found before it.
|
||||
assert_eq!(
|
||||
largest_layer0_size("4.size,1.0,3.800,3.600;4.size,1.0,4.99"),
|
||||
Some((800, 600))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_size_fits_and_is_even() {
|
||||
assert_eq!(fit_size(Some((1280, 720)), (1920, 1080)), (1280, 720));
|
||||
assert_eq!(fit_size(Some((2556, 1278)), (1920, 1080)), (1920, 960));
|
||||
assert_eq!(fit_size(Some((1023, 767)), (1920, 1080)), (1022, 766));
|
||||
assert_eq!(fit_size(None, (1920, 1080)), (1280, 720));
|
||||
assert_eq!(fit_size(Some((0, 0)), (1920, 1080)), (1280, 720));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn converts_into_the_cache_and_leaves_the_recordings_alone() {
|
||||
let s = Scratch::new("convert");
|
||||
let rec = s.0.join("rec");
|
||||
std::fs::create_dir_all(&rec).unwrap();
|
||||
std::fs::write(rec.join("a.guac"), "4.size,1.0,4.1280,3.720;").unwrap();
|
||||
let conv = Arc::new(Mp4Converter::new(
|
||||
&config(&rec, &fake_guacenc(&s.0, true)),
|
||||
&rec,
|
||||
));
|
||||
assert_eq!(conv.state("a.guac"), Mp4State::None);
|
||||
assert_eq!(conv.start("a.guac", false, "u"), StartOutcome::Started);
|
||||
assert_eq!(
|
||||
settle(&conv, "a.guac").await,
|
||||
Mp4State::Ready { size_bytes: 5 }
|
||||
);
|
||||
assert!(conv.mp4_path("a.guac").ends_with("mp4-cache/a.mp4"));
|
||||
// Nothing new beside the recording, and no work directory left.
|
||||
let beside: Vec<_> = std::fs::read_dir(&rec)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.collect();
|
||||
assert_eq!(beside.len(), 2, "{beside:?}");
|
||||
let cached: Vec<_> = std::fs::read_dir(rec.join("mp4-cache"))
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.collect();
|
||||
assert_eq!(cached, vec!["a.mp4".to_string()]);
|
||||
// Asking again reuses it.
|
||||
assert_eq!(
|
||||
conv.start("a.guac", false, "u"),
|
||||
StartOutcome::AlreadyReady { size_bytes: 5 }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_output_is_a_failure_and_leaves_nothing() {
|
||||
let s = Scratch::new("fail");
|
||||
let rec = s.0.join("rec");
|
||||
std::fs::create_dir_all(&rec).unwrap();
|
||||
std::fs::write(rec.join("b.guac"), "x").unwrap();
|
||||
let conv = Arc::new(Mp4Converter::new(
|
||||
&config(&rec, &fake_guacenc(&s.0, false)),
|
||||
&rec,
|
||||
));
|
||||
assert_eq!(conv.start("b.guac", false, "u"), StartOutcome::Started);
|
||||
assert!(matches!(settle(&conv, "b.guac").await, Mp4State::Failed(_)));
|
||||
assert_eq!(std::fs::read_dir(rec.join("mp4-cache")).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_missing_and_unavailable_are_not_converted() {
|
||||
let s = Scratch::new("refuse");
|
||||
let rec = s.0.join("rec");
|
||||
std::fs::create_dir_all(&rec).unwrap();
|
||||
std::fs::write(rec.join("c.guac"), "x").unwrap();
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let _g = rt.enter();
|
||||
let conv = Arc::new(Mp4Converter::new(
|
||||
&config(&rec, &fake_guacenc(&s.0, true)),
|
||||
&rec,
|
||||
));
|
||||
assert_eq!(conv.start("c.guac", true, "u"), StartOutcome::Live);
|
||||
assert_eq!(conv.start("gone.guac", false, "u"), StartOutcome::NotFound);
|
||||
let off = Arc::new(Mp4Converter::new(
|
||||
&config(&rec, &s.0.join("no-such-guacenc")),
|
||||
&rec,
|
||||
));
|
||||
assert!(!off.enabled());
|
||||
assert_eq!(off.start("c.guac", false, "u"), StartOutcome::Unavailable);
|
||||
assert_eq!(off.state("c.guac"), Mp4State::Unavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_removes_orphans_work_dirs_and_everything_on_request() {
|
||||
let s = Scratch::new("purge");
|
||||
let rec = s.0.join("rec");
|
||||
let cache = rec.join("mp4-cache");
|
||||
std::fs::create_dir_all(cache.join(".work-old")).unwrap();
|
||||
std::fs::write(rec.join("kept.guac"), "x").unwrap();
|
||||
std::fs::write(cache.join("kept.mp4"), "v").unwrap();
|
||||
std::fs::write(cache.join("orphan.mp4"), "v").unwrap();
|
||||
let conv = Mp4Converter::new(&config(&rec, &fake_guacenc(&s.0, true)), &rec);
|
||||
assert_eq!(conv.purge(false), 2, "the orphan and the dead work dir");
|
||||
assert!(cache.join("kept.mp4").exists());
|
||||
assert_eq!(conv.purge(true), 1);
|
||||
assert!(!cache.join("kept.mp4").exists());
|
||||
}
|
||||
}
|
||||
+86
-1
@@ -439,9 +439,88 @@
|
||||
|
||||
function renderRecordings(recordings) {
|
||||
recView = sortRecordings(recordings, recSortCol, recSortAsc);
|
||||
// The server lists an `mp4` state per recording only when it can
|
||||
// convert them (#241). A state this page is tracking (a
|
||||
// conversion it started) wins over a stale listing.
|
||||
mp4Available = recordings.some(function(r) { return 'mp4' in r; });
|
||||
recordings.forEach(function(r) {
|
||||
if ('mp4' in r && !mp4Polling[r.name]) mp4State[r.name] = r.mp4;
|
||||
});
|
||||
renderRecPage();
|
||||
}
|
||||
|
||||
// ── MP4 downloads (#241) ──
|
||||
// Start a conversion, show its progress on the button by polling the
|
||||
// status, and download the file as soon as it is ready. Downloads go
|
||||
// through fetch so API-key sessions send their header, and are saved
|
||||
// with a.download, never opened as a page.
|
||||
var mp4Available = false;
|
||||
var mp4State = {}; // recording name -> none|running|ready|failed
|
||||
var mp4Polling = {}; // recording name -> true while this page polls
|
||||
var MP4_LABELS = { running: 'converting\u2026', ready: 'mp4 \u2193', failed: 'mp4 (retry)' };
|
||||
|
||||
function mp4Button(name) {
|
||||
var st = mp4State[name] || 'none';
|
||||
return '<button class="btn-small btn-mp4" data-mp4="' + escapeAttr(name) + '"' +
|
||||
(st === 'running' ? ' disabled' : '') +
|
||||
' title="Download as an MP4 video that plays anywhere">' +
|
||||
(MP4_LABELS[st] || 'mp4') + '</button>';
|
||||
}
|
||||
|
||||
function setMp4State(name, st) {
|
||||
mp4State[name] = st;
|
||||
var btn = document.querySelector('[data-mp4="' + CSS.escape(name) + '"]');
|
||||
if (btn) btn.outerHTML = mp4Button(name);
|
||||
}
|
||||
|
||||
function mp4Url(name, suffix) {
|
||||
return '/api/recordings/' + encodeURIComponent(name) + '/mp4' + (suffix || '');
|
||||
}
|
||||
|
||||
function downloadMp4(name) {
|
||||
fetch(mp4Url(name), { headers: apiHeaders(), credentials: 'same-origin' })
|
||||
.then(function(res) {
|
||||
if (!res.ok) throw new Error('The MP4 is no longer available; convert it again.');
|
||||
return res.blob();
|
||||
})
|
||||
.then(function(blob) {
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = name.replace(/\.guac$/, '') + '.mp4';
|
||||
a.click();
|
||||
setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000);
|
||||
})
|
||||
.catch(function(err) { setMp4State(name, 'none'); rgAlert(err.message); });
|
||||
}
|
||||
|
||||
function pollMp4(name) {
|
||||
mp4Polling[name] = true;
|
||||
fetch(mp4Url(name, '/status'), { headers: apiHeaders(), credentials: 'same-origin' })
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
if (data.state === 'running') { setTimeout(function() { pollMp4(name); }, 2000); return; }
|
||||
delete mp4Polling[name];
|
||||
setMp4State(name, data.state);
|
||||
if (data.state === 'ready') downloadMp4(name);
|
||||
else if (data.state === 'failed') rgAlert('MP4 conversion failed: ' + (data.error || 'unknown error'));
|
||||
})
|
||||
.catch(function() { setTimeout(function() { pollMp4(name); }, 5000); });
|
||||
}
|
||||
|
||||
function startMp4(name) {
|
||||
if (mp4State[name] === 'ready') { downloadMp4(name); return; }
|
||||
setMp4State(name, 'running');
|
||||
fetch(mp4Url(name), { method: 'POST', headers: apiHeaders(), credentials: 'same-origin' })
|
||||
.then(function(res) { return res.json().then(function(data) { return { res: res, data: data }; }); })
|
||||
.then(function(r) {
|
||||
if (r.res.status === 200 && r.data.state === 'ready') { setMp4State(name, 'ready'); downloadMp4(name); return; }
|
||||
if (r.res.status === 202) { pollMp4(name); return; }
|
||||
setMp4State(name, 'none');
|
||||
rgAlert(r.data.error || 'Could not start the MP4 conversion.');
|
||||
})
|
||||
.catch(function() { setMp4State(name, 'none'); rgAlert('Could not start the MP4 conversion.'); });
|
||||
}
|
||||
|
||||
function renderRecPage() {
|
||||
var container = document.getElementById('recording-list');
|
||||
var pager = document.getElementById('rec-pagination');
|
||||
@@ -465,7 +544,7 @@
|
||||
sortHeader('Folder', 'folder') +
|
||||
sortHeader('Size', 'size_bytes') +
|
||||
sortHeader('Date', 'modified') +
|
||||
'<th></th><th></th><th></th>' +
|
||||
'<th></th><th></th>' + (mp4Available ? '<th></th>' : '') + '<th></th>' +
|
||||
'</tr></thead><tbody>';
|
||||
slice.forEach(function(r) {
|
||||
var date = r.modified ? new Date(r.modified).toLocaleString() : (r.created_at ? new Date(r.created_at).toLocaleString() : '');
|
||||
@@ -480,6 +559,7 @@
|
||||
'<td>' + date + '</td>' +
|
||||
'<td><button class="btn-small" data-play="' + escapeAttr(r.name) + '">play</button></td>' +
|
||||
'<td><a class="btn-small" href="/api/recordings/' + encodeURIComponent(r.name) + '" download="' + escapeAttr(r.name) + '">download</a></td>' +
|
||||
(mp4Available ? '<td>' + mp4Button(r.name) + '</td>' : '') +
|
||||
'<td><button class="btn-small btn-delete" data-delete-rec="' + escapeAttr(r.name) + '">delete</button></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
@@ -506,6 +586,11 @@
|
||||
playRecording(playName);
|
||||
return;
|
||||
}
|
||||
var mp4Name = e.target.getAttribute('data-mp4');
|
||||
if (mp4Name) {
|
||||
startMp4(mp4Name);
|
||||
return;
|
||||
}
|
||||
var deleteName = e.target.getAttribute('data-delete-rec');
|
||||
if (deleteName) {
|
||||
rgConfirm('Delete recording ' + deleteName + '?', { danger: true, confirmText: 'Delete' }).then(function(ok) {
|
||||
|
||||
@@ -35,6 +35,7 @@ One spec per change, named after the branch that introduced it:
|
||||
| `global-search` | `/` opens the overlay on every signed-in page, folder view untouched; nothing on the login page, for anonymous or viewer callers, or from a previous login's cache |
|
||||
| `search-under-modals` | a page modal covers the search box |
|
||||
| `zero-click-fullscreen` | #257: same-tab iframe sized from the measured viewport |
|
||||
| `recordings-mp4` | #241: the mp4 button converts, shows progress and downloads; refused while live; hidden without server support |
|
||||
|
||||
The fixture suppresses the first-visit onboarding tour (`options.onboarding`
|
||||
re-enables it) and defaults to the admin role; pass `role` to test gating.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// #241: a recording can be downloaded as an MP4. The button starts a
|
||||
// conversion on the server, shows its progress, and downloads the video as
|
||||
// soon as it is ready, saved under the recording's name. Without MP4 support
|
||||
// on the server (no `mp4` field in the listing) there is no button.
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { installApi, settle, fixtureRecordings } from '../lib/api.mjs';
|
||||
|
||||
const NAME = 'rdp-dc01-20261011-020000.guac';
|
||||
const MP4 = '/api/recordings/' + encodeURIComponent(NAME) + '/mp4';
|
||||
|
||||
test('mp4 converts, shows progress, then downloads', async ({ page }) => {
|
||||
let polls = 0;
|
||||
const recordings = fixtureRecordings().map(r => ({ ...r, mp4: 'none' }));
|
||||
const api = await installApi(page, {
|
||||
recordings,
|
||||
routes: {
|
||||
[`POST ${MP4}`]: { status: 202, contentType: 'application/json', body: '{"state":"running"}' },
|
||||
// Running for two polls, then ready.
|
||||
[`GET ${MP4}/status`]: () => (++polls < 3 ? { state: 'running' } : { state: 'ready', size_bytes: 5 }),
|
||||
[`GET ${MP4}`]: { status: 200, contentType: 'video/mp4', body: 'video' }
|
||||
}
|
||||
});
|
||||
await page.goto('/recordings.html');
|
||||
await settle(page);
|
||||
|
||||
const btn = page.locator(`[data-mp4="${NAME}"]`);
|
||||
await expect(btn).toHaveText('mp4');
|
||||
const download = page.waitForEvent('download', { timeout: 15000 });
|
||||
await btn.click();
|
||||
await expect(page.locator(`[data-mp4="${NAME}"]`)).toHaveText(/converting/);
|
||||
const file = await download;
|
||||
expect(file.suggestedFilename()).toBe('rdp-dc01-20261011-020000.mp4');
|
||||
await expect(page.locator(`[data-mp4="${NAME}"]`)).toHaveText(/mp4 ↓/);
|
||||
expect(api.calls.filter(c => c.method === 'POST' && c.path === MP4)).toHaveLength(1);
|
||||
|
||||
// Ready: a second click downloads straight away, with no new conversion.
|
||||
const again = page.waitForEvent('download');
|
||||
await page.locator(`[data-mp4="${NAME}"]`).click();
|
||||
await again;
|
||||
expect(api.calls.filter(c => c.method === 'POST' && c.path === MP4)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a live recording is refused with a message, and the button resets', async ({ page }) => {
|
||||
const recordings = fixtureRecordings().map(r => ({ ...r, mp4: 'none' }));
|
||||
await installApi(page, {
|
||||
recordings,
|
||||
routes: {
|
||||
[`POST ${MP4}`]: { status: 409, contentType: 'application/json',
|
||||
body: '{"error":"this session is still being recorded; try again once it has ended"}' }
|
||||
}
|
||||
});
|
||||
await page.goto('/recordings.html');
|
||||
await settle(page);
|
||||
await page.locator(`[data-mp4="${NAME}"]`).click();
|
||||
await expect(page.getByText('still being recorded')).toBeVisible();
|
||||
await expect(page.locator(`[data-mp4="${NAME}"]`)).toHaveText('mp4');
|
||||
});
|
||||
|
||||
test('no mp4 button when the server cannot convert', async ({ page }) => {
|
||||
await installApi(page);
|
||||
await page.goto('/recordings.html');
|
||||
await settle(page);
|
||||
await expect(page.locator('#recording-list tbody tr').first()).toBeVisible();
|
||||
await expect(page.locator('[data-mp4]')).toHaveCount(0);
|
||||
});
|
||||
Reference in New Issue
Block a user