Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions scripts/check-rust-layout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@
# - Unit tests never sit inline in the code: `#[cfg(test)] mod tests;` names a
# `tests.rs` beside the module, and no file under `src/` opens `mod tests {`.
#
# Run from the repository root: `sh scripts/check-rust-layout.sh`. POSIX sh
# and coreutils only, so it runs the same on a runner and on a laptop.
# `sh scripts/check-rust-layout.sh`, from anywhere: it moves to the
# repository root first, because run from a subfolder its `find` met no file
# and it passed having measured nothing. POSIX sh and coreutils only, so it
# runs the same on a runner and on a laptop.

set -eu

cd "$(dirname "$0")/.."

SOURCE_LIMIT=800
TEST_LIMIT=1000

Expand Down
18 changes: 6 additions & 12 deletions scripts/check-webapp-layout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,21 @@
# file it tests) stays under 1,000 lines, as does any file under
# `webapp/e2e/`, specs and fixtures alike.
#
# Run from the repository root: `sh scripts/check-webapp-layout.sh`. POSIX sh
# and coreutils only, so it runs the same on a runner and on a laptop.
# `sh scripts/check-webapp-layout.sh`, from anywhere: it moves to the
# repository root first, because run from a subfolder its `find` met no file
# and it passed having measured nothing. POSIX sh and coreutils only, so it
# runs the same on a runner and on a laptop.

set -eu

cd "$(dirname "$0")/.."

CODE_LIMIT=600
STYLE_LIMIT=800
TEST_LIMIT=1000

# Files allowed past their limit, one path per line, each with its reason.
#
# webapp/e2e/smart-playlists-and-podcasts.spec.ts: 965 lines until biome first
# formatted `e2e/`, 1,102 after, by layout alone. Its two subjects
# share one mock whose state is module-level `let`s the tests reassign, and a
# binding imported from another module cannot be reassigned; splitting it
# means moving that state onto an object, as `e2e/fixtures/state.ts` does for
# the rest, and rewriting every read and write of it. That is a change to the
# tests, owed its own pull request, not a formatting pass; remove this entry
# with it.
ALLOWLIST="
webapp/e2e/smart-playlists-and-podcasts.spec.ts
"

failed=0
Expand Down
6 changes: 2 additions & 4 deletions src/app/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

use std::{sync::Arc, time::Duration};

use super::state::{chunk_body_limit, negotiation_body_limit, AppState};
use super::state::{canvas_body_limit, chunk_body_limit, negotiation_body_limit, AppState};
use crate::{
auth::authentication, auth::security, auth::setup_token, library::link_roots, library::scanner,
media, quality, remote::enrichment, remote::lrclib, remote::scrobblers, services,
Expand Down Expand Up @@ -199,9 +199,7 @@ async fn boot(db: database::Database, config: &Config) -> anyhow::Result<AppStat
refresh_token_ttl: config.refresh_token_ttl,
upload_batch_body_limit: negotiation_body_limit(&config.uploads),
upload_chunk_body_limit: chunk_body_limit(&config.uploads),
// Refused at startup if it does not fit, so this conversion cannot be
// the place that decides what a ceiling falls back to.
canvas_body_limit: usize::try_from(config.canvas.max_bytes).unwrap_or(usize::MAX),
canvas_body_limit: canvas_body_limit(&config.canvas),
trusted_proxies: config.trusted_proxies.clone().into(),
setup,
})
Expand Down
12 changes: 12 additions & 0 deletions src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ pub(super) fn negotiation_body_limit(limits: &config::UploadLimits) -> usize {
limits.batch_limit.saturating_mul(160).saturating_add(1024)
}

/// The canvas route's body ceiling: the largest canvas, and nothing more.
///
/// `Config::from_env` refuses a size this platform cannot represent, but a
/// `Config` built by hand never meets that check, and `max_bytes` is signed. A
/// negative or oversized value lands on the default ceiling, never on
/// `usize::MAX` — the reason `chunk_body_limit` below gives for its own
/// fallback.
pub(super) fn canvas_body_limit(limits: &config::CanvasLimits) -> usize {
const FALLBACK: usize = 4 * 1024 * 1024;
usize::try_from(limits.max_bytes).unwrap_or(FALLBACK)
}

/// One fragment, plus room for its framing.
///
/// `Config` refuses a fragment size this platform cannot represent, so the
Expand Down
19 changes: 19 additions & 0 deletions src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,22 @@ fn every_public_ticket_route_has_a_redaction_prefix() {
);
}
}

/// A canvas size the platform cannot represent falls back to a finite ceiling.
///
/// `Config::from_env` refuses such a size, but a `Config` built by hand never
/// meets that check. The conversion used to fall back to `usize::MAX`, which
/// is no ceiling at all on the one route that takes a whole video in a body.
#[test]
fn a_canvas_size_that_does_not_fit_keeps_a_finite_ceiling() {
use super::state::canvas_body_limit;
use crate::config::CanvasLimits;

let limits = |max_bytes| CanvasLimits {
max_bytes,
max_duration_secs: 8,
library_quota_bytes: i64::MAX,
};
assert_eq!(canvas_body_limit(&limits(256 * 1024)), 256 * 1024);
assert_eq!(canvas_body_limit(&limits(-1)), 4 * 1024 * 1024);
}
10 changes: 9 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
//! Administrative CLI used before the M4 web administration surface exists.
//! The command line: what an operator does on the host itself — accounts and
//! credentials, libraries and their members, database backups and restores,
//! the instance key and scrobbling links.
//!
//! It predates the web administration and outlived it on purpose: it is how the
//! first administrator is made without the setup page, and how a library is
//! administered from a shell script. Most commands run against the same
//! `AppState` the server builds; `restore` and `key` take only the
//! `Config`, because they run on a database the boot would refuse.

mod account;
mod args;
Expand Down
33 changes: 7 additions & 26 deletions src/config/from_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,8 @@ impl Config {
// Zero is a value here and not a refusal — "no expiry" is a choice an
// operator may make — so it is read like the scan interval rather than
// through `parse_positive_env`.
let api_token_ttl_secs = std::env::var("WAVEFLOW_API_TOKEN_TTL_SECS")
.unwrap_or_else(|_| "31536000".to_owned())
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("invalid WAVEFLOW_API_TOKEN_TTL_SECS: {error}"))?;
let scan_interval_secs = std::env::var("WAVEFLOW_SCAN_INTERVAL_SECS")
.unwrap_or_else(|_| "21600".to_owned())
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("invalid WAVEFLOW_SCAN_INTERVAL_SECS: {error}"))?;
let api_token_ttl_secs = parse_env::<u64>("WAVEFLOW_API_TOKEN_TTL_SECS", "31536000")?;
let scan_interval_secs = parse_env::<u64>("WAVEFLOW_SCAN_INTERVAL_SECS", "21600")?;
let scan_parallelism = parse_positive_env(
"WAVEFLOW_SCAN_PARALLELISM",
std::thread::available_parallelism()
Expand All @@ -65,19 +59,12 @@ impl Config {
let watch_delay =
Duration::from_secs(parse_positive_env("WAVEFLOW_WATCH_DELAY_SECS", 10u64)?);
// Zero is a setting, the default one: never purge on its own.
let unavailable_retention_days = std::env::var("WAVEFLOW_UNAVAILABLE_RETENTION_DAYS")
.unwrap_or_else(|_| "0".to_owned())
.parse::<u64>()
.map_err(|error| {
anyhow::anyhow!("invalid WAVEFLOW_UNAVAILABLE_RETENTION_DAYS: {error}")
})?;
let unavailable_retention_days =
parse_env::<u64>("WAVEFLOW_UNAVAILABLE_RETENTION_DAYS", "0")?;
let unavailable_retention = (unavailable_retention_days > 0)
.then(|| Duration::from_secs(unavailable_retention_days.saturating_mul(24 * 60 * 60)));
// Zero is a setting too: no way back, as before.
let purge_grace_hours = std::env::var("WAVEFLOW_PURGE_GRACE_HOURS")
.unwrap_or_else(|_| "168".to_owned())
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("invalid WAVEFLOW_PURGE_GRACE_HOURS: {error}"))?;
let purge_grace_hours = parse_env::<u64>("WAVEFLOW_PURGE_GRACE_HOURS", "168")?;
let purge_grace = Duration::from_secs(purge_grace_hours.saturating_mul(60 * 60));
let link_roots = parse_link_roots(std::env::var_os("WAVEFLOW_LINK_ROOTS"))?;
let subsonic_replay_gain = match std::env::var("WAVEFLOW_SUBSONIC_REPLAYGAIN") {
Expand All @@ -102,10 +89,7 @@ impl Config {
parse_positive_env("WAVEFLOW_TRANSCODE_PER_USER_LIMIT", 2usize)?;
// Zero is a setting here, not a mistake: it is how an operator turns the
// analysis off, so this is not `parse_positive_env`.
let quality_parallelism = std::env::var("WAVEFLOW_QUALITY_PARALLELISM")
.unwrap_or_else(|_| "1".to_owned())
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("invalid WAVEFLOW_QUALITY_PARALLELISM: {error}"))?;
let quality_parallelism = parse_env::<usize>("WAVEFLOW_QUALITY_PARALLELISM", "1")?;
let uploads = UploadLimits {
max_file_bytes: parse_positive_env("WAVEFLOW_UPLOAD_MAX_FILE_BYTES", 1_073_741_824i64)?,
library_quota_bytes: parse_positive_env(
Expand Down Expand Up @@ -259,10 +243,7 @@ impl Config {
let setup_token = parse_setup_token(read_trimmed_env("WAVEFLOW_SETUP_TOKEN"))?;
// Zero is a setting, not a mistake: an operator with backups of their
// own turns the copies off with it.
let migration_backups = std::env::var("WAVEFLOW_MIGRATION_BACKUPS")
.unwrap_or_else(|_| "3".to_owned())
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("invalid WAVEFLOW_MIGRATION_BACKUPS: {error}"))?;
let migration_backups = parse_env::<usize>("WAVEFLOW_MIGRATION_BACKUPS", "3")?;

let allowed_origins = std::env::var("WAVEFLOW_ALLOWED_ORIGINS")
.unwrap_or_default()
Expand Down
9 changes: 2 additions & 7 deletions src/config/podcasts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

use std::{path::PathBuf, time::Duration};

use super::env::{parse_bool_env, parse_positive_env};
use super::env::{parse_bool_env, parse_env, parse_positive_env};

/// Podcasts (RFC-025 decisions 7 to 12).
///
Expand Down Expand Up @@ -75,12 +75,7 @@ pub struct RemoteArtworkSettings {
pub(super) fn parse_podcast_settings(
data_dir: &std::path::Path,
) -> anyhow::Result<PodcastSettings> {
let refresh_secs = std::env::var("WAVEFLOW_PODCAST_REFRESH_INTERVAL_SECS")
.unwrap_or_else(|_| "21600".to_owned())
.parse::<u64>()
.map_err(|error| {
anyhow::anyhow!("invalid WAVEFLOW_PODCAST_REFRESH_INTERVAL_SECS: {error}")
})?;
let refresh_secs = parse_env::<u64>("WAVEFLOW_PODCAST_REFRESH_INTERVAL_SECS", "21600")?;
let max_episode_bytes = parse_positive_env("WAVEFLOW_PODCAST_MAX_EPISODE_BYTES", 1u64 << 30)?;
let quota_bytes = parse_positive_env("WAVEFLOW_PODCAST_QUOTA_BYTES", 20u64 << 30)?;
if max_episode_bytes > quota_bytes {
Expand Down
26 changes: 26 additions & 0 deletions webapp/e2e/admin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,32 @@ test("lists who may see a library, once its panel is opened", async ({
);
});

/**
* The roles are words for a reader, not the API's enum. The account rows
* printed `admin` and an English "libraries" whatever the language, and the
* role picker offered `listener` and `manager` as they cross the wire.
*/
test("names the roles and the library count in the reader's language", async ({
page,
}) => {
await page.addInitScript(() => localStorage.setItem("waveflow.locale", "fr"));
await page.goto("/admin");

await expect(page.getByText("administrateur · 1 bibliothèque")).toBeVisible();

await page
.getByRole("button", { name: "Qui peut voir cette bibliothèque" })
.first()
.click();
await expect(page.getByText("propriétaire", { exact: true })).toBeVisible();
const picker = page.getByRole("combobox", { name: "Rôle: guest" });
await expect(picker).toHaveValue("listener");
await expect(picker.locator("option")).toHaveText([
"auditeur",
"gestionnaire",
]);
});

/**
* A membership list that could not be read is not an empty one. Standing an
* empty array in for "not known yet" made every account on the server look like
Expand Down
7 changes: 2 additions & 5 deletions webapp/e2e/artist.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { resetAndMockApi } from "./fixtures/api";
import { openImageMenu, serveArtistImages } from "./fixtures/artist-images";
import { albums, artistDetail, library } from "./fixtures/catalogue";
import { PIXEL } from "./fixtures/media";
import { mock } from "./fixtures/state";

test.beforeEach(resetAndMockApi);
Expand Down Expand Up @@ -101,12 +102,8 @@ test("an artist page shows what the server learned, and where each part came fro
};
// Only the portrait's hash answers with an image: the artist row names
// none, so the hero can show it only through `enrichment.image`.
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
await page.route(`**/api/v2/artwork/${"a".repeat(64)}`, async (route) => {
await route.fulfill({ contentType: "image/png", body: png });
await route.fulfill({ contentType: "image/png", body: PIXEL });
});

await page.goto("/artists/artist-1");
Expand Down
61 changes: 5 additions & 56 deletions webapp/e2e/editorial-pages.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, type Page, test } from "@playwright/test";

import { expectNoWcagViolations } from "./fixtures/accessibility";
import { albums, session } from "./fixtures/catalogue";
import { PIXEL } from "./fixtures/media";

/**
* The editorial pass over four screens: the playing screen in two columns,
* the mixes set apart from the albums, the artist's image actions behind one
Expand All @@ -11,12 +14,6 @@ import { expect, type Page, test } from "@playwright/test";
* to the routes these screens read. Anything else answers 404, as there.
*/

const session = {
access_token: "e2e-access-token",
user: { id: "user-1", username: "listener", role: "admin" },
device_id: "device-1",
};

const library = (role: "owner" | "manager" | "listener") => ({
id: "library-1",
name: "Ma musique",
Expand All @@ -29,31 +26,6 @@ const library = (role: "owner" | "manager" | "listener") => ({
follow_links: false,
});

const albums = [
{
id: "album-1",
library_id: "library-1",
title: "Post",
artist: "Björk",
artist_id: "artist-1",
artwork_hash: null,
year: 1995,
starred_at: null,
user_rating: null,
},
{
id: "album-2",
library_id: "library-1",
title: "Vespertine",
artist: "Björk",
artist_id: "artist-1",
artwork_hash: null,
year: 2001,
starred_at: null,
user_rating: null,
},
];

const song = (index: number, title: string) => ({
id: `song-${index}`,
library_id: "library-1",
Expand Down Expand Up @@ -115,29 +87,6 @@ const longSheet = (): Sheet[] => [
},
];

const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);

async function expectNoWcagViolations(page: Page) {
await page.evaluate(() =>
Promise.all(
document
.getAnimations()
.filter(
(animation) =>
animation.effect?.getComputedTiming().endTime !== Infinity,
)
.map((animation) => animation.finished.catch(() => undefined)),
),
);
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations).toEqual([]);
}

async function mockApi(page: Page) {
await page.context().addCookies([
{
Expand All @@ -162,7 +111,7 @@ async function mockApi(page: Page) {
});
}
if (path.startsWith("/api/v2/artwork/")) {
return route.fulfill({ contentType: "image/png", body: png });
return route.fulfill({ contentType: "image/png", body: PIXEL });
}
if (
path === "/api/v2/bookmarks" ||
Expand Down
Loading
Loading