super-tts-daemon/src/daemon/http/v1/mod.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! `/v1` — one module per path, named for the path it answers on. |
3 | | //! |
4 | | //! A directory where a path has sub-resources worth separating ([`backends`], |
5 | | //! [`pipeline`], [`registry`], [`settings`]); a file otherwise, holding that |
6 | | //! path and any sub-path small enough to read beside it. Two modules are named |
7 | | //! for something other than a path because they are not endpoints: [`macros`], |
8 | | //! which generates the one-value settings handlers, and [`wire`], the narrow |
9 | | //! response bodies they answer with. |
10 | | //! |
11 | | //! `/auth` is the exception: its routes are `super_engine_daemon::auth::routes`, |
12 | | //! shared with Super STT, and only registered here. |
13 | | |
14 | | // Must come first: `#[macro_use]` puts the settings-endpoint macros in scope for |
15 | | // every module declared after it. |
16 | | #[macro_use] |
17 | | mod macros; |
18 | | |
19 | | pub(crate) mod backends; |
20 | | pub(crate) mod events; |
21 | | pub(crate) mod gpu_info; |
22 | | pub(crate) mod ping; |
23 | | pub(crate) mod pipeline; |
24 | | pub(crate) mod registry; |
25 | | pub(crate) mod settings; |
26 | | pub(crate) mod speak; |
27 | | pub(crate) mod speak_stream; |
28 | | pub(crate) mod status; |
29 | | pub(crate) mod update; |
30 | | pub(crate) mod voice; |
31 | | pub(crate) mod wire; |
32 | | |
33 | | use crate::daemon::http::internal::auth::{require_speak_scope, require_voices_scope}; |
34 | | use crate::daemon::http::openapi::ApiDoc; |
35 | | use crate::daemon::http::state::AppState; |
36 | | use axum::Router; |
37 | | use axum::middleware; |
38 | | use super_engine_daemon::auth::middleware::{ |
39 | | require_allowed_origin, require_any_authenticated, require_rate_limit, require_secrets_scope, |
40 | | require_settings_scope, require_status_scope, |
41 | | }; |
42 | | use super_engine_daemon::auth::routes as auth; |
43 | | use utoipa::OpenApi; |
44 | | use utoipa_axum::router::OpenApiRouter; |
45 | | use utoipa_axum::routes; |
46 | | |
47 | | /// The `/v1` routes, grouped by the guard they share. |
48 | | /// |
49 | | /// Registering a route and documenting it is one act here: every entry goes in |
50 | | /// through [`routes!`], which reads the `#[utoipa::path]` attribute off the |
51 | | /// handler it names. A handler without one does not compile, and a path spelled |
52 | | /// two ways cannot exist, because there is only one spelling. |
53 | | /// |
54 | | /// The guards are applied separately, in [`router`], for a practical reason: |
55 | | /// `from_fn_with_state` needs a live [`AppState`], and building one opens the |
56 | | /// system keyring. Keeping route registration free of it is what lets |
57 | | /// [`openapi`] generate the document from these same registrations without a |
58 | | /// running daemon — see `src/bin/gen_openapi.rs`. |
59 | | struct ScopeGroups { |
60 | | /// Any valid token, whatever its scopes. `GET /events` lives here because |
61 | | /// its per-topic scope is enforced inside the handler, against the topics |
62 | | /// actually requested. |
63 | | any: OpenApiRouter<AppState>, |
64 | | status: OpenApiRouter<AppState>, |
65 | | speak: OpenApiRouter<AppState>, |
66 | | /// The configuration surface: every module whose paths the `settings` |
67 | | /// scope guards, gathered by [`settings_routes`]. |
68 | | settings: OpenApiRouter<AppState>, |
69 | | secrets: OpenApiRouter<AppState>, |
70 | | /// The cloned-voice library. Its own scope rather than part of `settings`: |
71 | | /// these are recordings of a person, and an app that manages models has no |
72 | | /// business reading them. |
73 | | voices: OpenApiRouter<AppState>, |
74 | | /// Reachable without a token — it is how a caller gets one. |
75 | | unauthenticated: OpenApiRouter<AppState>, |
76 | | } |
77 | | |
78 | | /// Every `/v1` route, in its group. Paths are bare here (`/ping`); the `/v1` |
79 | | /// prefix is applied once, in [`assemble`]. |
80 | 90 | fn scope_groups() -> ScopeGroups { |
81 | | ScopeGroups { |
82 | 90 | any: OpenApiRouter::new() |
83 | 90 | .routes(routes!(ping::ping)) |
84 | 90 | .routes(routes!(auth::auth_status)) |
85 | 90 | .merge(events::routes()), |
86 | 90 | status: OpenApiRouter::new().routes(routes!(status::status)), |
87 | 90 | speak: speak::routes().merge(speak_stream::routes()), |
88 | 90 | settings: settings_routes(), |
89 | 90 | secrets: backends::secrets::routes(), |
90 | 90 | voices: voice::routes(), |
91 | 90 | unauthenticated: OpenApiRouter::new().routes(routes!(auth::auth_request)), |
92 | | } |
93 | 90 | } |
94 | | |
95 | | /// Every route the `settings` scope guards, gathered from the modules that hold |
96 | | /// them. This is the one place the scope's membership is written down; the |
97 | | /// paths themselves live in each module's `#[utoipa::path]`, which is what |
98 | | /// `routes!` reads them back off. |
99 | | /// |
100 | | /// The families with their own trees ([`backends`], [`pipeline`], [`registry`], |
101 | | /// [`settings`]) contribute their own `routes()`. [`backends::secrets`] is |
102 | | /// deliberately not among them — those paths carry the `secrets` scope and are |
103 | | /// guarded separately in [`scope_groups`]. |
104 | 90 | fn settings_routes() -> OpenApiRouter<AppState> { |
105 | 90 | OpenApiRouter::new() |
106 | 90 | .routes(routes!(gpu_info::get_gpu_info)) |
107 | 90 | .routes(routes!(update::get_update)) |
108 | 90 | .routes(routes!(update::post_check)) |
109 | 90 | .merge(settings::routes()) |
110 | 90 | .merge(backends::routes()) |
111 | 90 | .merge(pipeline::routes()) |
112 | 90 | .merge(registry::routes()) |
113 | 90 | } |
114 | | |
115 | | /// Apply each group's guard. Rate limiting is layered under the scope check on |
116 | | /// every guarded group, so an unauthenticated flood is rejected on the cheaper |
117 | | /// test first. |
118 | 70 | fn guarded(groups: ScopeGroups, state: &AppState) -> ScopeGroups { |
119 | | /// `.layer(scope).layer(rate_limit)` — the pair every guarded group takes. |
120 | | macro_rules! guard { |
121 | | ($group:expr, $scope:expr) => { |
122 | | $group |
123 | | .layer(middleware::from_fn_with_state(state.auth.clone(), $scope)) |
124 | | .layer(middleware::from_fn_with_state( |
125 | | state.auth.clone(), |
126 | | require_rate_limit, |
127 | | )) |
128 | | }; |
129 | | } |
130 | | |
131 | 70 | ScopeGroups { |
132 | 70 | any: guard!(groups.any, require_any_authenticated), |
133 | 70 | status: guard!(groups.status, require_status_scope), |
134 | 70 | speak: guard!(groups.speak, require_speak_scope), |
135 | 70 | settings: guard!(groups.settings, require_settings_scope), |
136 | 70 | secrets: guard!(groups.secrets, require_secrets_scope), |
137 | 70 | voices: guard!(groups.voices, require_voices_scope), |
138 | 70 | // No guard: this is the endpoint that mints the token the others need. |
139 | 70 | unauthenticated: groups.unauthenticated, |
140 | 70 | } |
141 | 70 | } |
142 | | |
143 | | /// Merge the groups under `/v1` and attach the base document. The live router |
144 | | /// and the generated spec both come through here, so the two cannot describe |
145 | | /// different route sets. |
146 | 85 | fn assemble(groups: ScopeGroups) -> OpenApiRouter<AppState> { |
147 | 85 | let v1 = OpenApiRouter::new() |
148 | 85 | .merge(groups.any) |
149 | 85 | .merge(groups.status) |
150 | 85 | .merge(groups.speak) |
151 | 85 | .merge(groups.settings) |
152 | 85 | .merge(groups.secrets) |
153 | 85 | .merge(groups.voices) |
154 | 85 | .merge(groups.unauthenticated); |
155 | | |
156 | 85 | OpenApiRouter::with_openapi(ApiDoc::openapi()).nest("/v1", v1) |
157 | 85 | } |
158 | | |
159 | | /// The live `/v1` router, guards applied and state bound. |
160 | | /// |
161 | | /// The origin gate wraps everything, including `/v1/auth/request` — the one |
162 | | /// route no scope guards. That is deliberate: `auth_request` is what opens a |
163 | | /// consent dialog, so a page from an unlisted origin must be stopped before it |
164 | | /// can put a popup on the user's screen, not merely stopped from using the |
165 | | /// token it would mint. |
166 | 70 | pub(crate) fn router(state: AppState) -> Router { |
167 | 70 | let (router, _spec) = assemble(guarded(scope_groups(), &state)).split_for_parts(); |
168 | 70 | router |
169 | 70 | .layer(middleware::from_fn_with_state( |
170 | 70 | state.auth.clone(), |
171 | | require_allowed_origin, |
172 | | )) |
173 | 70 | .with_state(state) |
174 | 70 | } |
175 | | |
176 | | /// The generated document for the same surface. The guards are omitted because |
177 | | /// they add no paths — what each endpoint requires is stated in its own |
178 | | /// `security` and prose. |
179 | 15 | pub(crate) fn openapi() -> utoipa::openapi::OpenApi { |
180 | 15 | let (_router, mut spec) = assemble(scope_groups()).split_for_parts(); |
181 | | // Has to happen here, not as an `ApiDoc` modifier: the paths only exist |
182 | | // once the router has been assembled. |
183 | 15 | crate::daemon::http::openapi::annotate_scopes(&mut spec); |
184 | 15 | spec |
185 | 15 | } |
186 | | |
187 | | /// Which scope the router *enforces* on each `/v1` path, read from the grouping |
188 | | /// itself rather than restated. |
189 | | /// |
190 | | /// `None` means the path is reachable without a token. `Some(vec![])` means any |
191 | | /// valid token will do, whatever its scopes. |
192 | | /// |
193 | | /// This exists so the contract test can check each endpoint's advertised |
194 | | /// `security` against the guard it actually sits behind. The two are written in |
195 | | /// different places — the scope in a `#[utoipa::path]` attribute, the guard in |
196 | | /// [`guarded`] — and nothing else would notice them disagreeing. A client author |
197 | | /// told they need `settings` for an endpoint the daemon guards with `secrets` |
198 | | /// requests the wrong scope and gets a `403` they cannot explain. |
199 | | #[cfg(test)] |
200 | 5 | pub(super) fn enforced_scopes() -> std::collections::BTreeMap<String, Option<Vec<&'static str>>> { |
201 | 5 | let groups = scope_groups(); |
202 | 5 | let by_group = [ |
203 | 5 | (groups.any, Some(vec![])), |
204 | 5 | (groups.status, Some(vec!["status"])), |
205 | 5 | (groups.speak, Some(vec!["speak"])), |
206 | 5 | (groups.settings, Some(vec!["settings"])), |
207 | 5 | (groups.secrets, Some(vec!["secrets"])), |
208 | 5 | (groups.voices, Some(vec!["voices"])), |
209 | 5 | (groups.unauthenticated, None), |
210 | 5 | ]; |
211 | | |
212 | 5 | let mut enforced = std::collections::BTreeMap::new(); |
213 | 35 | for (router, scopes) in by_group5 { |
214 | | // The groups hold bare paths; `assemble` is what applies the `/v1` |
215 | | // prefix, so apply it here too to match the published document. |
216 | 35 | let (_router, spec) = router.split_for_parts(); |
217 | 255 | for path in spec.paths.paths35 .keys35 () { |
218 | 255 | enforced.insert(format!("/v1{path}"), scopes.clone()); |
219 | 255 | } |
220 | | } |
221 | 5 | enforced |
222 | 5 | } |