mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(media): use LGPL FFmpeg build
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -99,7 +99,13 @@ jobs:
|
||||
- name: Install media privacy tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends ffmpeg libimage-exiftool-perl
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential ffmpeg libimage-exiftool-perl libopenh264-dev \
|
||||
nasm pkg-config xz-utils zlib1g-dev
|
||||
FFMPEG_PREFIX="$RUNNER_TEMP/buzz-ffmpeg" ./scripts/build-ffmpeg-lgpl.sh
|
||||
echo "BUZZ_FFMPEG_PATH=$RUNNER_TEMP/buzz-ffmpeg/bin/ffmpeg" >> "$GITHUB_ENV"
|
||||
echo "BUZZ_FFPROBE_PATH=$RUNNER_TEMP/buzz-ffmpeg/bin/ffprobe" >> "$GITHUB_ENV"
|
||||
echo "BUZZ_FIXTURE_FFMPEG_PATH=/usr/bin/ffmpeg" >> "$GITHUB_ENV"
|
||||
- name: Unit tests
|
||||
run: just test-unit
|
||||
- name: Media compliance tests
|
||||
|
||||
+10
-6
@@ -44,7 +44,7 @@ unacceptable behavior to **conduct@buzz-relay.org**.
|
||||
| pnpm | 10+ | Required for desktop app commands and `just ci` |
|
||||
| Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) |
|
||||
| Docker | 24+ | For Postgres, Redis, MinIO |
|
||||
| FFmpeg + ffprobe | 6+ | Media sanitization; pinned by Hermit |
|
||||
| FFmpeg + ffprobe | 6+ | Media sanitization; install with your OS package manager |
|
||||
| ExifTool | 12.70+ | Image metadata removal and verification; install with your OS package manager |
|
||||
| `just` | latest | Task runner — `cargo install just` |
|
||||
| `lefthook` | latest | Optional; run `lefthook install` for local Git hooks |
|
||||
@@ -92,11 +92,15 @@ Adminer on `:8082`, Keycloak on `:8180` for local OAuth/OIDC testing, MinIO on
|
||||
pending database migrations.
|
||||
|
||||
The relay validates its privacy toolchain during startup and fails closed when
|
||||
FFmpeg, ffprobe, ExifTool, or a required codec is unavailable. Hermit supplies
|
||||
FFmpeg and ffprobe. Install ExifTool separately (for example,
|
||||
`brew install exiftool` on macOS or `apt install libimage-exiftool-perl` on
|
||||
Debian/Ubuntu). Override binary locations with `BUZZ_EXIFTOOL_PATH`,
|
||||
`BUZZ_FFMPEG_PATH`, and `BUZZ_FFPROBE_PATH` when needed.
|
||||
FFmpeg, ffprobe, ExifTool, or a required codec is unavailable. Install the
|
||||
media tools separately for local development (for example,
|
||||
`brew install ffmpeg exiftool` on macOS or
|
||||
`apt install ffmpeg libimage-exiftool-perl` on Debian/Ubuntu). Buzz rejects
|
||||
FFmpeg builds configured with `--enable-nonfree`. The published relay image
|
||||
instead builds an LGPL FFmpeg/ffprobe toolchain with BSD-licensed OpenH264 from
|
||||
source and includes the corresponding source archive and build configuration.
|
||||
Override binary locations with `BUZZ_EXIFTOOL_PATH`, `BUZZ_FFMPEG_PATH`, and
|
||||
`BUZZ_FFPROBE_PATH` when needed.
|
||||
|
||||
### Running the Relay and Desktop App
|
||||
|
||||
|
||||
+29
-2
@@ -109,7 +109,31 @@ RUN pnpm install --frozen-lockfile --filter buzz-web
|
||||
COPY web/ web/
|
||||
RUN pnpm -C web build
|
||||
|
||||
# ─── Stage 5: runtime ───────────────────────────────────────────────────────
|
||||
# ─── Stage 5: LGPL media toolchain ─────────────────────────────────────────
|
||||
# Debian's ffmpeg package enables GPL components such as libx264. Build the two
|
||||
# standalone tools used by the relay from upstream source instead, with GPL and
|
||||
# non-redistributable components disabled. OpenH264 is BSD-licensed and keeps
|
||||
# the relay's canonical H.264/AAC MP4 output without pulling libx264 into the
|
||||
# published image. The exact corresponding source and build configuration are
|
||||
# retained under /opt/ffmpeg/share/source in the runtime image.
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS media-tools-builder
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libopenh264-dev \
|
||||
nasm \
|
||||
pkg-config \
|
||||
xz-utils \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY scripts/build-ffmpeg-lgpl.sh /usr/local/bin/build-ffmpeg-lgpl
|
||||
RUN /usr/local/bin/build-ffmpeg-lgpl
|
||||
|
||||
# ─── Stage 6: runtime ───────────────────────────────────────────────────────
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS runtime
|
||||
|
||||
# OCI annotations: required for GHCR to auto-link the image to this repo and
|
||||
@@ -126,9 +150,9 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
ffmpeg \
|
||||
git \
|
||||
libimage-exiftool-perl \
|
||||
libopenh264-7 \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 1000 buzz \
|
||||
@@ -139,6 +163,9 @@ COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-rela
|
||||
COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin
|
||||
COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
|
||||
COPY --from=web-builder /build/web/dist /srv/buzz/web
|
||||
COPY --from=media-tools-builder /opt/ffmpeg /opt/ffmpeg
|
||||
|
||||
ENV PATH="/opt/ffmpeg/bin:${PATH}"
|
||||
|
||||
# The invite landing page is always served from the bundled web UI. Repository
|
||||
# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
.ffmpeg-6.1.1.pkg
|
||||
@@ -1 +0,0 @@
|
||||
.ffprobe-6.1.1.pkg
|
||||
@@ -36,6 +36,9 @@ pub struct ToolVersions {
|
||||
pub ffmpeg: String,
|
||||
/// Full first version line reported by ffprobe.
|
||||
pub ffprobe: String,
|
||||
/// H.264 encoder selected from the verified FFmpeg capabilities.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub video_encoder: Option<String>,
|
||||
}
|
||||
|
||||
/// Return the startup-verified sanitizer versions for private audit records.
|
||||
@@ -89,6 +92,8 @@ pub async fn validate_toolchain(config: &MediaConfig) -> Result<(), MediaError>
|
||||
let exiftool = successful_version(&config.exiftool_path, "-ver").await?;
|
||||
let ffmpeg = successful_version(&config.ffmpeg_path, "-version").await?;
|
||||
let ffprobe = successful_version(&config.ffprobe_path, "-version").await?;
|
||||
reject_nonredistributable_build(&config.ffmpeg_path).await?;
|
||||
reject_nonredistributable_build(&config.ffprobe_path).await?;
|
||||
let encoders = run_tool(
|
||||
&config.ffmpeg_path,
|
||||
&["-hide_banner", "-encoders"],
|
||||
@@ -96,14 +101,8 @@ pub async fn validate_toolchain(config: &MediaConfig) -> Result<(), MediaError>
|
||||
)
|
||||
.await?;
|
||||
let encoders = String::from_utf8_lossy(&encoders.stdout);
|
||||
for required in [
|
||||
"libx264",
|
||||
"aac",
|
||||
"libaom-av1",
|
||||
"libvpx-vp9",
|
||||
"libopus",
|
||||
"libvorbis",
|
||||
] {
|
||||
let video_encoder = select_video_encoder(&encoders).ok_or(MediaError::ToolUnavailable)?;
|
||||
for required in ["aac"] {
|
||||
if !encoders.contains(required) {
|
||||
return Err(MediaError::ToolUnavailable);
|
||||
}
|
||||
@@ -136,10 +135,50 @@ pub async fn validate_toolchain(config: &MediaConfig) -> Result<(), MediaError>
|
||||
exiftool,
|
||||
ffmpeg,
|
||||
ffprobe,
|
||||
video_encoder: Some(video_encoder.to_string()),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reject_nonredistributable_build(program: &str) -> Result<(), MediaError> {
|
||||
let output = run_tool(
|
||||
program,
|
||||
&["-hide_banner", "-buildconf"],
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
return Err(MediaError::ToolUnavailable);
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if build_is_nonredistributable(&stdout) || build_is_nonredistributable(&stderr) {
|
||||
return Err(MediaError::ToolUnavailable);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_is_nonredistributable(configuration: &str) -> bool {
|
||||
configuration
|
||||
.split_ascii_whitespace()
|
||||
.any(|argument| argument == "--enable-nonfree")
|
||||
}
|
||||
|
||||
fn select_video_encoder(encoders: &str) -> Option<&'static str> {
|
||||
["libopenh264", "libx264"].into_iter().find(|encoder| {
|
||||
encoders
|
||||
.split_ascii_whitespace()
|
||||
.any(|word| word == *encoder)
|
||||
})
|
||||
}
|
||||
|
||||
fn video_encoder() -> Result<&'static str, MediaError> {
|
||||
TOOL_VERSIONS
|
||||
.get()
|
||||
.and_then(|versions| versions.video_encoder.as_deref())
|
||||
.ok_or(MediaError::ToolUnavailable)
|
||||
}
|
||||
|
||||
async fn successful_version(program: &str, arg: &str) -> Result<String, MediaError> {
|
||||
let output = run_tool(program, &[arg], Duration::from_secs(15)).await?;
|
||||
if !output.status.success() {
|
||||
@@ -467,9 +506,25 @@ async fn sanitize_video(
|
||||
if can_copy {
|
||||
args.extend(strings(&["-c:v", "copy", "-c:a", "copy"]));
|
||||
} else {
|
||||
args.extend(strings(&[
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-c:a", "aac", "-b:a", "192k",
|
||||
]));
|
||||
match video_encoder()? {
|
||||
"libopenh264" => args.extend(strings(&[
|
||||
"-c:v",
|
||||
"libopenh264",
|
||||
"-b:v",
|
||||
"4M",
|
||||
"-maxrate",
|
||||
"4M",
|
||||
"-bufsize",
|
||||
"8M",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
])),
|
||||
"libx264" => args.extend(strings(&[
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||
])),
|
||||
_ => return Err(MediaError::ToolUnavailable),
|
||||
}
|
||||
args.extend(strings(&["-c:a", "aac", "-b:a", "192k"]));
|
||||
}
|
||||
args.extend(strings(&[
|
||||
"-movflags",
|
||||
@@ -1036,6 +1091,27 @@ mod tests {
|
||||
assert!(!is_structural_tag("title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonredistributable_ffmpeg_builds_are_rejected() {
|
||||
assert!(build_is_nonredistributable(
|
||||
"configuration: --enable-gpl --enable-nonfree --enable-libx264"
|
||||
));
|
||||
assert!(!build_is_nonredistributable(
|
||||
"configuration: --disable-autodetect --enable-libopenh264 --enable-shared"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openh264_is_preferred_without_requiring_it_from_operators() {
|
||||
let both = " V....D libx264 H.264 / AVC\n V....D libopenh264 OpenH264 H.264";
|
||||
assert_eq!(select_video_encoder(both), Some("libopenh264"));
|
||||
assert_eq!(
|
||||
select_video_encoder(" V....D libx264 H.264 / AVC"),
|
||||
Some("libx264")
|
||||
);
|
||||
assert_eq!(select_video_encoder(" A..... aac AAC"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_and_animation_limits_fail_closed() {
|
||||
let mut probe = MediaProbe {
|
||||
|
||||
+5
-3
@@ -1,9 +1,11 @@
|
||||
# Media compliance fixtures
|
||||
|
||||
The compliance test builds deterministic, synthetic fixtures at runtime so
|
||||
codec/container coverage follows the exact FFmpeg and ExifTool binaries shipped
|
||||
with the relay. Coordinates are fictional test data (`41.8781, -87.6298`) and
|
||||
no fixture contains a real person or device identifier.
|
||||
codec/container coverage follows the configured FFmpeg and ExifTool binaries.
|
||||
The published relay image builds its FFmpeg/ffprobe executables from the pinned
|
||||
source and configuration in `scripts/build-ffmpeg-lgpl.sh`. Coordinates are
|
||||
fictional test data (`41.8781, -87.6298`) and no fixture contains a real person
|
||||
or device identifier.
|
||||
|
||||
`tiny.heic.b64` is the sole prebuilt input because FFmpeg does not provide a
|
||||
portable HEIC muxer. It is a 64×64 synthetic application icon converted by
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use base64::Engine;
|
||||
@@ -7,15 +7,6 @@ use buzz_media::MediaConfig;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn config() -> MediaConfig {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let hermit = |name: &str| {
|
||||
let path = root.join("bin").join(name);
|
||||
if path.exists() {
|
||||
path.to_string_lossy().into_owned()
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
};
|
||||
MediaConfig {
|
||||
s3_endpoint: String::new(),
|
||||
s3_access_key: String::new(),
|
||||
@@ -30,8 +21,8 @@ fn config() -> MediaConfig {
|
||||
public_base_url: "http://localhost:3000/media".to_string(),
|
||||
exiftool_path: std::env::var("BUZZ_EXIFTOOL_PATH")
|
||||
.unwrap_or_else(|_| "exiftool".to_string()),
|
||||
ffmpeg_path: std::env::var("BUZZ_FFMPEG_PATH").unwrap_or_else(|_| hermit("ffmpeg")),
|
||||
ffprobe_path: std::env::var("BUZZ_FFPROBE_PATH").unwrap_or_else(|_| hermit("ffprobe")),
|
||||
ffmpeg_path: std::env::var("BUZZ_FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()),
|
||||
ffprobe_path: std::env::var("BUZZ_FFPROBE_PATH").unwrap_or_else(|_| "ffprobe".to_string()),
|
||||
image_process_timeout_secs: 120,
|
||||
av_process_timeout_secs: 600,
|
||||
upload_records_enabled: false,
|
||||
@@ -59,7 +50,9 @@ fn path(path: &Path) -> &str {
|
||||
fn ffmpeg(config: &MediaConfig, args: &[&str]) {
|
||||
let mut full = vec!["-nostdin", "-v", "error", "-y"];
|
||||
full.extend_from_slice(args);
|
||||
run(&config.ffmpeg_path, &full);
|
||||
let generator =
|
||||
std::env::var("BUZZ_FIXTURE_FFMPEG_PATH").unwrap_or_else(|_| config.ffmpeg_path.clone());
|
||||
run(&generator, &full);
|
||||
}
|
||||
|
||||
fn add_private_metadata(config: &MediaConfig, fixture: &Path) {
|
||||
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Build the relay's FFmpeg/ffprobe toolchain without GPL or non-redistributable
|
||||
# components. The only external codec enabled here is BSD-licensed OpenH264;
|
||||
# all other media support comes from FFmpeg's LGPL implementation.
|
||||
|
||||
set -eu
|
||||
|
||||
: "${FFMPEG_VERSION:=7.1.5}"
|
||||
: "${FFMPEG_SHA256:=de668509caf9e35e3cd162473441fdb29538c6d96ed080292b3cf9e6fc5d558f}"
|
||||
: "${FFMPEG_PREFIX:=/opt/ffmpeg}"
|
||||
|
||||
archive="/tmp/ffmpeg-${FFMPEG_VERSION}.tar.xz"
|
||||
source_dir="/tmp/ffmpeg-${FFMPEG_VERSION}"
|
||||
|
||||
curl --fail --location --silent --show-error \
|
||||
"https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" \
|
||||
--output "${archive}"
|
||||
echo "${FFMPEG_SHA256} ${archive}" | sha256sum --check --strict
|
||||
tar --extract --file "${archive}" --directory /tmp
|
||||
|
||||
cd "${source_dir}"
|
||||
./configure \
|
||||
--prefix="${FFMPEG_PREFIX}" \
|
||||
--disable-autodetect \
|
||||
--disable-debug \
|
||||
--disable-doc \
|
||||
--disable-ffplay \
|
||||
--disable-network \
|
||||
--disable-static \
|
||||
--enable-libopenh264 \
|
||||
--enable-shared \
|
||||
--extra-ldflags="-Wl,-rpath,${FFMPEG_PREFIX}/lib" \
|
||||
--enable-zlib
|
||||
|
||||
make --jobs="$(getconf _NPROCESSORS_ONLN)"
|
||||
make install
|
||||
|
||||
license_dir="${FFMPEG_PREFIX}/share/licenses/ffmpeg"
|
||||
openh264_license_dir="${FFMPEG_PREFIX}/share/licenses/openh264"
|
||||
source_archive_dir="${FFMPEG_PREFIX}/share/source/ffmpeg"
|
||||
mkdir -p "${license_dir}" "${openh264_license_dir}" "${source_archive_dir}"
|
||||
cp COPYING.LGPLv2.1 LICENSE.md "${license_dir}/"
|
||||
cp /usr/share/doc/libopenh264-dev/copyright "${openh264_license_dir}/"
|
||||
cp "${archive}" "${source_archive_dir}/"
|
||||
|
||||
"${FFMPEG_PREFIX}/bin/ffmpeg" -hide_banner -buildconf \
|
||||
> "${source_archive_dir}/build-configuration.txt" 2>&1
|
||||
|
||||
if grep -q -- '--enable-gpl\|--enable-nonfree' \
|
||||
"${source_archive_dir}/build-configuration.txt"; then
|
||||
echo "Refusing to package a GPL or non-redistributable FFmpeg build" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"${FFMPEG_PREFIX}/bin/ffmpeg" -hide_banner -encoders 2>&1 \
|
||||
| grep -q 'libopenh264'
|
||||
"${FFMPEG_PREFIX}/bin/ffprobe" -hide_banner -version >/dev/null
|
||||
Reference in New Issue
Block a user