diff --git a/.env.example b/.env.example index 200f70a..1531310 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ AUTOSUBS_MAX_TRANSCRIPTION_JOBS=2 AUTOSUBS_MAX_QUEUED_JOBS=256 # Optional first-boot transcription bootstrap. After the DB exists, Settings in the UI are authoritative. +# Prefer OpenAI-compatible base URLs ending in /v1; AutoSubs derives /models and /audio/transcriptions. AUTOSUBS_TRANSCRIPTION_LANGUAGE=fr AUTOSUBS_TRANSCRIPTION_URL= AUTOSUBS_TRANSCRIPTION_MODEL= diff --git a/CHANGELOG.md b/CHANGELOG.md index 9103906..fd0a9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable released changes are documented here. AutoSubs follows semantic vers ## [Unreleased] +## [3.1.2] - 2026-09-17 + +### Fixed + +- OpenAI-compatible `/v1` provider URLs now resolve `/models` for discovery and `/audio/transcriptions` for transcription, while full transcription URLs remain accepted. +- Multipart asset uploads larger than Axum's default body limit are streamed correctly and remain capped by `AUTOSUBS_MAX_UPLOAD_BYTES`. + +### Security + +- Updated `rustls` to 0.23.45 and `chacha20` to 0.10.2 so the locked dependency set passes the current RustSec audit. + ## [3.1.1] - 2026-09-02 ### Added diff --git a/Cargo.lock b/Cargo.lock index 0048b7c..8f53ce2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autosubs" -version = "3.1.1" +version = "3.1.2" dependencies = [ "anyhow", "async-stream", @@ -272,9 +272,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", @@ -1473,9 +1473,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 71ccb4e..831863b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "autosubs" -version = "3.1.1" +version = "3.1.2" edition = "2024" rust-version = "1.98" authors = ["GodsQuantum"] diff --git a/README.fr.md b/README.fr.md index dd130fc..85815db 100644 --- a/README.fr.md +++ b/README.fr.md @@ -186,6 +186,8 @@ Variables d'initialisation d'une DB vide : `AUTOSUBS_TRANSCRIPTION_LANGUAGE`, `A Les clés fournisseur peuvent être initialisées par environnement ou configurées ensuite dans Settings/API ; les secrets enregistrés ne sont jamais renvoyés en clair au navigateur. +Pour les fournisseurs compatibles OpenAI, renseigne de préférence l'URL de base terminant par `/v1` (par exemple `http://speaches:8000/v1` ou `https://api.groq.com/openai/v1`). AutoSubs dérive automatiquement `/models` pour la découverte et `/audio/transcriptions` pour la transcription. Une URL complète finissant par `/audio/transcriptions` reste acceptée. + ## API L'API actuelle vit sous `/api/v1` : jobs, suppression sans suppression des médias, retranscription/rerendu, streaming Range, annulation, sidecars, édition/regroupement/shift/export, uploads tus, catalogue de polices (`/fonts`, `/fonts/css`, `/fonts/{id}/content`), presets, marques, workflows, settings, picker de fichiers et assets. Voir la liste détaillée dans le [README anglais](README.md#api). diff --git a/README.md b/README.md index 6ed8809..0db9672 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ services: AUTOSUBS_MAX_RENDER_JOBS: "2" AUTOSUBS_MAX_TRANSCRIPTION_JOBS: "2" AUTOSUBS_LOCAL_TRANSCRIPTION_ENABLED: "true" - AUTOSUBS_LOCAL_TRANSCRIPTION_URL: http://transcriber:8000/v1/audio/transcriptions + AUTOSUBS_LOCAL_TRANSCRIPTION_URL: http://transcriber:8000/v1 volumes: - ./config:/config - ./data:/data @@ -125,6 +125,8 @@ services: Provider environment variables **bootstrap an empty database only**. After first start, Settings in the UI are authoritative. That avoids a container restart unexpectedly overwriting a key/URL you changed from the UI. +For OpenAI-compatible providers, use the provider base URL ending in `/v1` (for example `http://speaches:8000/v1` or `https://api.groq.com/openai/v1`). AutoSubs derives `/models` for discovery and `/audio/transcriptions` for transcription. A full `.../audio/transcriptions` URL is also accepted for backward compatibility. + For migration, the old `SPEACHES_URL` variable is still accepted as a first-boot alias for `AUTOSUBS_LOCAL_TRANSCRIPTION_URL`. ## 🎬 Manual production flow diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 399c86d..bd8dc5b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "autosubs-frontend", - "version": "3.1.1", + "version": "3.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "autosubs-frontend", - "version": "3.1.1", + "version": "3.1.2", "devDependencies": { "@sveltejs/adapter-static": "3.0.10", "@sveltejs/kit": "2.70.3", diff --git a/frontend/package.json b/frontend/package.json index b847ef6..01409dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "autosubs-frontend", "private": true, - "version": "3.1.1", + "version": "3.1.2", "type": "module", "scripts": { "dev": "vite dev", diff --git a/frontend/tests/integration-docs.test.mjs b/frontend/tests/integration-docs.test.mjs index 955559a..7f61184 100644 --- a/frontend/tests/integration-docs.test.mjs +++ b/frontend/tests/integration-docs.test.mjs @@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'; const root = new URL('../../', import.meta.url); -test('v3.1.1 integration docs expose the shipped version and lifecycle API', async () => { +test('v3.1.2 integration docs expose the shipped version and lifecycle API', async () => { const [cargo, frontend, lock, changelog, readme] = await Promise.all([ readFile(new URL('Cargo.toml', root), 'utf8'), readFile(new URL('frontend/package.json', root), 'utf8'), @@ -13,9 +13,9 @@ test('v3.1.1 integration docs expose the shipped version and lifecycle API', asy readFile(new URL('README.md', root), 'utf8') ]); - assert.match(cargo, /version = "3\.1\.1"/); - assert.match(frontend, /"version": "3\.1\.1"/); - assert.match(lock, /"version": "3\.1\.1"/g); + assert.match(cargo, /version = "3\.1\.2"/); + assert.match(frontend, /"version": "3\.1\.2"/); + assert.match(lock, /"version": "3\.1\.2"/g); for (const endpoint of ['/api/v1/fonts', '/api/v1/fonts/css', '/api/v1/jobs/{id}/retranscribe']) { assert.ok(readme.includes(endpoint), `missing README endpoint: ${endpoint}`); } diff --git a/src/api/mod.rs b/src/api/mod.rs index f6d1dc6..5f827ef 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -11,6 +11,7 @@ pub mod uploads; use crate::{error::AppResult, state::AppState}; use axum::{ Json, Router, + extract::DefaultBodyLimit, routing::{delete, get, options, post, put}, }; use serde::Serialize; @@ -113,7 +114,10 @@ pub fn router() -> Router { .route("/api/v1/browse", get(browse::browse)) .route( "/api/v1/assets", - get(assets::list_assets).post(assets::upload_asset), + get(assets::list_assets) + .post(assets::upload_asset) + // Multipart is streamed and capped by AUTOSUBS_MAX_UPLOAD_BYTES in the handler. + .layer(DefaultBodyLimit::disable()), ) .route("/api/v1/assets/import", post(assets::import_asset)) .route("/api/v1/assets/{id}", delete(assets::delete_asset)) @@ -135,3 +139,85 @@ pub fn router() -> Router { ) .route("/api/events", get(events::events)) } + +#[cfg(test)] +mod multipart_regression_tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn config(root: &std::path::Path) -> Config { + Config { + host: "127.0.0.1".into(), + port: 0, + config_dir: root.join("config"), + data_dir: root.join("data"), + fonts_dir: root.join("fonts"), + dist_dir: PathBuf::new(), + allowed_roots: vec![root.join("data")], + max_render_jobs: 1, + max_transcription_jobs: 1, + max_queued_jobs: 8, + workflow_scan_seconds: 5, + file_stability_ms: 0, + max_upload_bytes: 10 * 1024 * 1024, + } + } + + #[tokio::test] + async fn asset_multipart_upload_can_exceed_axum_default_body_limit() { + let dir = tempfile::tempdir().unwrap(); + let state = AppState::load(config(dir.path())).await.unwrap(); + let app = router().with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let payload = vec![b'x'; 3 * 1024 * 1024]; + let part = reqwest::multipart::Part::bytes(payload) + .file_name("outro.mp4") + .mime_str("video/mp4") + .unwrap(); + let response = reqwest::Client::new() + .post(format!("http://{addr}/api/v1/assets")) + .multipart(reqwest::multipart::Form::new().part("file", part)) + .send() + .await + .unwrap(); + assert!( + response.status().is_success(), + "status={}", + response.status() + ); + } + + #[tokio::test] + async fn asset_multipart_upload_still_respects_configured_maximum() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = config(dir.path()); + cfg.max_upload_bytes = 1024 * 1024; + let state = AppState::load(cfg).await.unwrap(); + let app = router().with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let payload = vec![b'x'; 2 * 1024 * 1024]; + let response = reqwest::Client::new() + .post(format!("http://{addr}/api/v1/assets")) + .multipart(reqwest::multipart::Form::new().part( + "file", + reqwest::multipart::Part::bytes(payload).file_name("too-large.mp4"), + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + assert_eq!( + std::fs::read_dir(dir.path().join("data/assets")) + .unwrap() + .count(), + 0 + ); + } +} diff --git a/src/api/settings.rs b/src/api/settings.rs index 6d79588..a0b012b 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -1,6 +1,7 @@ use crate::{ domain::{Encoder, Settings}, error::{AppError, AppResult}, + media::transcribe::models_endpoint, state::AppState, }; use axum::{Json, extract::State}; @@ -171,8 +172,7 @@ pub async fn list_models( State(state): State, Json(body): Json, ) -> AppResult> { - let endpoint = body.endpoint.trim_end_matches('/'); - let url = format!("{endpoint}/models"); + let url = models_endpoint(&body.endpoint); let mut request = state.http.get(url); if !body.api_key.is_empty() { request = request.bearer_auth(body.api_key); @@ -199,3 +199,54 @@ pub async fn list_models( models.sort(); Ok(Json(Models { models })) } + +#[cfg(test)] +mod model_endpoint_regression_tests { + use super::*; + use crate::config::Config; + use axum::{Json as AxumJson, Router, routing::get}; + use serde_json::json; + use std::path::PathBuf; + + fn config(root: &std::path::Path) -> Config { + Config { + host: "127.0.0.1".into(), + port: 0, + config_dir: root.join("config"), + data_dir: root.join("data"), + fonts_dir: root.join("fonts"), + dist_dir: PathBuf::new(), + allowed_roots: vec![root.join("data")], + max_render_jobs: 1, + max_transcription_jobs: 1, + max_queued_jobs: 8, + workflow_scan_seconds: 5, + file_stability_ms: 0, + max_upload_bytes: 10 * 1024 * 1024, + } + } + + #[tokio::test] + async fn full_transcription_endpoint_still_discovers_v1_models() { + let app = Router::new().route( + "/v1/models", + get(|| async { AxumJson(json!({"data": [{"id": "demo-model"}]})) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let dir = tempfile::tempdir().unwrap(); + let state = AppState::load(config(dir.path())).await.unwrap(); + let response = list_models( + State(state), + Json(ModelRequest { + endpoint: format!("http://{addr}/v1/audio/transcriptions"), + api_key: String::new(), + }), + ) + .await + .unwrap(); + assert_eq!(response.0.models, vec!["demo-model"]); + } +} diff --git a/src/media/transcribe.rs b/src/media/transcribe.rs index 5e6ee72..ae90557 100644 --- a/src/media/transcribe.rs +++ b/src/media/transcribe.rs @@ -38,6 +38,30 @@ pub async fn extract_audio( run_capture(command, token).await.map(|_| ()) } +pub(crate) fn endpoint_base(url: &str) -> String { + let mut endpoint = url.trim().trim_end_matches('/').to_owned(); + for suffix in ["/audio/transcriptions", "/models"] { + if endpoint.ends_with(suffix) { + endpoint.truncate(endpoint.len() - suffix.len()); + break; + } + } + endpoint +} + +pub(crate) fn transcription_endpoint(url: &str) -> String { + let endpoint = url.trim().trim_end_matches('/'); + if endpoint.ends_with("/audio/transcriptions") { + endpoint.to_owned() + } else { + format!("{}/audio/transcriptions", endpoint_base(endpoint)) + } +} + +pub(crate) fn models_endpoint(url: &str) -> String { + format!("{}/models", endpoint_base(url)) +} + async fn request_endpoint( url: &str, api_key: &str, @@ -50,6 +74,7 @@ async fn request_endpoint( if url.trim().is_empty() { return Err(TranscriptionError::NotConfigured); } + let url = transcription_endpoint(url); let file = File::open(audio).await?; let stream = ReaderStream::new(file).map_err(std::io::Error::other); let body = reqwest::Body::wrap_stream(stream); @@ -193,3 +218,37 @@ pub async fn transcribe_audio( } Err(TranscriptionError::NotConfigured) } + +#[cfg(test)] +mod endpoint_regression_tests { + use super::*; + use axum::{Json, Router, routing::post}; + use serde_json::json; + + #[tokio::test] + async fn base_v1_endpoint_transcribes_via_audio_transcriptions() { + let app = Router::new().route( + "/v1/audio/transcriptions", + post(|| async { Json(json!({"text": "ok"})) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let dir = tempfile::tempdir().unwrap(); + let audio = dir.path().join("sample.wav"); + tokio::fs::write(&audio, b"RIFFtest").await.unwrap(); + let settings = Settings { + local_transcription_enabled: false, + transcription_url: format!("http://{addr}/v1"), + transcription_model: "demo".into(), + language: "fr".into(), + ..Settings::default() + }; + + let result = transcribe_audio(&audio, &settings, &Client::new(), &CancellationToken::new()) + .await + .unwrap(); + assert_eq!(result.text.as_deref(), Some("ok")); + } +}