1+ use std:: collections:: HashMap ;
12use std:: path:: Path ;
23use std:: sync:: Arc ;
34
45use axum:: extract:: { Path as AxumPath , State } ;
5- use axum:: response:: Json ;
6+ use axum:: http:: StatusCode ;
7+ use axum:: response:: { IntoResponse , Json , Response } ;
8+ use bollard:: Docker ;
9+ use reqwest:: Client ;
10+ use serde:: Deserialize ;
611use tokio:: sync:: broadcast;
712
8- use crate :: config:: { load_config, Settings } ;
13+ use crate :: config:: {
14+ load_config, DockerSystemConfig , QBittorrentSystemConfig , Settings , SystemConfigType ,
15+ } ;
916use crate :: services:: collector_manager:: CollectorManager ;
1017use crate :: services:: metrics_store:: MetricsStore ;
1118
@@ -14,6 +21,7 @@ pub struct AppState {
1421 pub manager : tokio:: sync:: Mutex < CollectorManager > ,
1522 pub settings : Settings ,
1623 pub broadcast_tx : broadcast:: Sender < String > ,
24+ pub system_configs : HashMap < String , SystemConfigType > ,
1725}
1826
1927pub async fn root ( ) -> Json < serde_json:: Value > {
@@ -67,3 +75,233 @@ pub async fn reload_config(
6775 "systems" : ids,
6876 } ) )
6977}
78+
79+ // --- Action request types ---
80+
81+ #[ derive( Deserialize ) ]
82+ pub struct DockerActionRequest {
83+ pub container_name : String ,
84+ pub action : String ,
85+ }
86+
87+ #[ derive( Deserialize ) ]
88+ pub struct QbitActionRequest {
89+ pub hash : String ,
90+ pub action : String ,
91+ #[ serde( default ) ]
92+ pub delete_files : bool ,
93+ }
94+
95+ // --- Action error response helper ---
96+
97+ fn action_error ( status : StatusCode , msg : & str ) -> Response {
98+ ( status, Json ( serde_json:: json!( { "error" : msg} ) ) ) . into_response ( )
99+ }
100+
101+ // --- Docker action handler ---
102+
103+ fn connect_docker ( config : & DockerSystemConfig ) -> Result < Docker , String > {
104+ if let Some ( ref host) = config. host {
105+ Docker :: connect_with_http ( host, 10 , bollard:: API_DEFAULT_VERSION )
106+ . map_err ( |e| format ! ( "Docker connect failed: {e}" ) )
107+ } else {
108+ Docker :: connect_with_socket ( & config. socket , 10 , bollard:: API_DEFAULT_VERSION )
109+ . map_err ( |e| format ! ( "Docker socket connect failed: {e}" ) )
110+ }
111+ }
112+
113+ pub async fn docker_action (
114+ State ( state) : State < Arc < AppState > > ,
115+ AxumPath ( system_id) : AxumPath < String > ,
116+ Json ( body) : Json < DockerActionRequest > ,
117+ ) -> Response {
118+ // Look up Docker config
119+ let config = match state. system_configs . get ( & system_id) {
120+ Some ( SystemConfigType :: Docker ( c) ) => c. clone ( ) ,
121+ _ => return action_error ( StatusCode :: NOT_FOUND , "Docker system not found" ) ,
122+ } ;
123+
124+ // Validate action
125+ if !matches ! ( body. action. as_str( ) , "start" | "stop" | "restart" ) {
126+ return action_error (
127+ StatusCode :: BAD_REQUEST ,
128+ & format ! ( "Invalid action: {}" , body. action) ,
129+ ) ;
130+ }
131+
132+ // Connect
133+ let client = match connect_docker ( & config) {
134+ Ok ( c) => c,
135+ Err ( e) => return action_error ( StatusCode :: INTERNAL_SERVER_ERROR , & e) ,
136+ } ;
137+
138+ // Execute action
139+ let result = match body. action . as_str ( ) {
140+ "start" => client
141+ . start_container :: < String > ( & body. container_name , None )
142+ . await
143+ . map ( |_| ( ) ) ,
144+ "stop" => client
145+ . stop_container ( & body. container_name , None )
146+ . await
147+ . map ( |_| ( ) ) ,
148+ "restart" => client
149+ . restart_container ( & body. container_name , None )
150+ . await
151+ . map ( |_| ( ) ) ,
152+ _ => unreachable ! ( ) ,
153+ } ;
154+
155+ match result {
156+ Ok ( ( ) ) => {
157+ tracing:: info!(
158+ "[{}] Docker action '{}' on container '{}'" ,
159+ system_id,
160+ body. action,
161+ body. container_name
162+ ) ;
163+ (
164+ StatusCode :: OK ,
165+ Json ( serde_json:: json!( {
166+ "status" : "success" ,
167+ "action" : body. action,
168+ "container" : body. container_name,
169+ } ) ) ,
170+ )
171+ . into_response ( )
172+ }
173+ Err ( e) => {
174+ tracing:: error!(
175+ "[{}] Docker action '{}' failed on '{}': {}" ,
176+ system_id,
177+ body. action,
178+ body. container_name,
179+ e
180+ ) ;
181+ action_error ( StatusCode :: INTERNAL_SERVER_ERROR , & e. to_string ( ) )
182+ }
183+ }
184+ }
185+
186+ // --- qBittorrent action handler ---
187+
188+ async fn qbit_authenticate ( client : & Client , config : & QBittorrentSystemConfig ) -> Result < ( ) , String > {
189+ let resp = client
190+ . post ( format ! ( "{}/api/v2/auth/login" , config. url) )
191+ . form ( & [
192+ ( "username" , config. username . as_str ( ) ) ,
193+ ( "password" , config. password . as_str ( ) ) ,
194+ ] )
195+ . send ( )
196+ . await
197+ . map_err ( |e| format ! ( "qBittorrent login request failed: {e}" ) ) ?;
198+
199+ let text = resp. text ( ) . await . map_err ( |e| e. to_string ( ) ) ?;
200+ if text != "Ok." {
201+ return Err ( "qBittorrent authentication failed" . to_string ( ) ) ;
202+ }
203+ Ok ( ( ) )
204+ }
205+
206+ pub async fn qbit_action (
207+ State ( state) : State < Arc < AppState > > ,
208+ AxumPath ( system_id) : AxumPath < String > ,
209+ Json ( body) : Json < QbitActionRequest > ,
210+ ) -> Response {
211+ // Look up qBit config
212+ let config = match state. system_configs . get ( & system_id) {
213+ Some ( SystemConfigType :: QBittorrent ( c) ) => c. clone ( ) ,
214+ _ => return action_error ( StatusCode :: NOT_FOUND , "qBittorrent system not found" ) ,
215+ } ;
216+
217+ // Validate action
218+ if !matches ! ( body. action. as_str( ) , "resume" | "pause" | "delete" ) {
219+ return action_error (
220+ StatusCode :: BAD_REQUEST ,
221+ & format ! ( "Invalid action: {}" , body. action) ,
222+ ) ;
223+ }
224+
225+ // Create client with cookie store for session
226+ let client = match Client :: builder ( )
227+ . danger_accept_invalid_certs ( true )
228+ . cookie_store ( true )
229+ . timeout ( std:: time:: Duration :: from_secs ( 10 ) )
230+ . build ( )
231+ {
232+ Ok ( c) => c,
233+ Err ( e) => return action_error ( StatusCode :: INTERNAL_SERVER_ERROR , & e. to_string ( ) ) ,
234+ } ;
235+
236+ // Authenticate
237+ if let Err ( e) = qbit_authenticate ( & client, & config) . await {
238+ return action_error ( StatusCode :: INTERNAL_SERVER_ERROR , & e) ;
239+ }
240+
241+ // Build the qBittorrent API request
242+ let ( endpoint, form_data) = match body. action . as_str ( ) {
243+ "resume" => (
244+ format ! ( "{}/api/v2/torrents/resume" , config. url) ,
245+ vec ! [ ( "hashes" . to_string( ) , body. hash. clone( ) ) ] ,
246+ ) ,
247+ "pause" => (
248+ format ! ( "{}/api/v2/torrents/pause" , config. url) ,
249+ vec ! [ ( "hashes" . to_string( ) , body. hash. clone( ) ) ] ,
250+ ) ,
251+ "delete" => (
252+ format ! ( "{}/api/v2/torrents/delete" , config. url) ,
253+ vec ! [
254+ ( "hashes" . to_string( ) , body. hash. clone( ) ) ,
255+ ( "deleteFiles" . to_string( ) , body. delete_files. to_string( ) ) ,
256+ ] ,
257+ ) ,
258+ _ => unreachable ! ( ) ,
259+ } ;
260+
261+ // Execute
262+ let result = client. post ( & endpoint) . form ( & form_data) . send ( ) . await ;
263+
264+ match result {
265+ Ok ( resp) if resp. status ( ) . is_success ( ) => {
266+ tracing:: info!(
267+ "[{}] qBit action '{}' on torrent '{}'" ,
268+ system_id,
269+ body. action,
270+ body. hash
271+ ) ;
272+ (
273+ StatusCode :: OK ,
274+ Json ( serde_json:: json!( {
275+ "status" : "success" ,
276+ "action" : body. action,
277+ "hash" : body. hash,
278+ } ) ) ,
279+ )
280+ . into_response ( )
281+ }
282+ Ok ( resp) => {
283+ let status = resp. status ( ) ;
284+ let text = resp. text ( ) . await . unwrap_or_default ( ) ;
285+ tracing:: error!(
286+ "[{}] qBit action '{}' failed: {} {}" ,
287+ system_id,
288+ body. action,
289+ status,
290+ text
291+ ) ;
292+ action_error (
293+ StatusCode :: INTERNAL_SERVER_ERROR ,
294+ & format ! ( "qBittorrent API returned {status}" ) ,
295+ )
296+ }
297+ Err ( e) => {
298+ tracing:: error!(
299+ "[{}] qBit action '{}' request failed: {}" ,
300+ system_id,
301+ body. action,
302+ e
303+ ) ;
304+ action_error ( StatusCode :: INTERNAL_SERVER_ERROR , & e. to_string ( ) )
305+ }
306+ }
307+ }
0 commit comments