rustream/routes/
media.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use actix_web::{HttpRequest, HttpResponse, web};
6use actix_web::http::StatusCode;
7use fernet::Fernet;
8use minijinja;
9use serde::Deserialize;
10use url::form_urlencoded;
11
12
13use crate::{constant, routes, squire};
14
15/// Represents the payload structure for deserializing data from the request query parameters.
16#[derive(Deserialize)]
17pub struct Payload {
18    file: String,
19}
20
21/// Represents the paths and filenames for subtitles, including both SRT and VTT formats.
22struct Subtitles {
23    srt: PathBuf,
24    vtt: PathBuf,
25    vtt_file: String,
26}
27
28/// URL encodes the provided path string.
29///
30/// This function takes a reference to a `String` representing a path,
31/// encodes it using the `form_urlencoded` crate, and returns the encoded string.
32///
33/// # Arguments
34///
35/// * `path` - The input path string to be URL encoded.
36///
37/// ## References
38/// - [rustjobs.dev](https://rustjobs.dev/blog/how-to-url-encode-strings-in-rust/)
39///
40/// # Returns
41///
42/// Returns a URL encoded string.
43fn url_encode(path: &String) -> String {
44    form_urlencoded::byte_serialize(path.as_bytes())
45        .collect::<Vec<_>>()
46        .join("")
47}
48
49/// Constructs a `Subtitles` struct based on the provided `target` path and `target_str`.
50///
51/// # Arguments
52///
53/// * `true_path` - True path of the requested file.
54/// * `relative_path` - The string representation of the relative filepath.
55///
56/// # Returns
57///
58/// Returns a `Subtitles` struct containing paths and filenames for both SRT and VTT subtitle files.
59fn subtitles(true_path: PathBuf, relative_path: &String) -> Subtitles {
60    // Set srt and vtt extensions to true path to check if they exist
61    let srt = true_path.with_extension("srt");
62    let vtt = true_path.with_extension("vtt");
63
64    // Set vtt extension to the relative path, so it could be used as a parameter in HTML
65    let vtt_filepath = PathBuf::new().join(relative_path).with_extension("vtt");
66    let vtt_file = vtt_filepath.to_string_lossy().to_string();
67
68    Subtitles { srt, vtt, vtt_file }
69}
70
71/// Handles requests for the '/track/{track_path:.*}' endpoint, serving track files.
72///
73/// # Arguments
74///
75/// * `request` - A reference to the Actix web `HttpRequest` object.
76/// * `info` - Query string from the request.
77/// * `fernet` - Fernet object to encrypt the auth payload that will be set as `session_token` cookie.
78/// * `session` - Session struct that holds the `session_mapping` and `session_tracker` to handle sessions.
79/// * `metadata` - Struct containing metadata of the application.
80/// * `config` - Configuration data for the application.
81/// * `template` - Configuration container for the loaded templates.
82///
83/// # Returns
84///
85/// Returns an `HttpResponse` containing the track file content or an error response.
86#[get("/track")]
87pub async fn track(request: HttpRequest,
88                   info: web::Query<Payload>,
89                   fernet: web::Data<Arc<Fernet>>,
90                   session: web::Data<Arc<constant::Session>>,
91                   metadata: web::Data<Arc<constant::MetaData>>,
92                   config: web::Data<Arc<squire::settings::Config>>,
93                   template: web::Data<Arc<minijinja::Environment<'static>>>) -> HttpResponse {
94    let auth_response = squire::authenticator::verify_token(&request, &config, &fernet, &session);
95    if !auth_response.ok {
96        return routes::auth::failed_auth(auth_response, &config);
97    }
98    if !squire::authenticator::verify_secure_index(&PathBuf::from(&info.file), &auth_response.username) {
99        return squire::custom::error(
100            "RESTRICTED SECTION",
101            template.get_template("error").unwrap(),
102            &metadata.pkg_version,
103            format!("This content is not accessible, as it does not belong to the user profile '{}'", auth_response.username),
104            StatusCode::FORBIDDEN
105        );
106    }
107    let (_host, _last_accessed) = squire::custom::log_connection(&request, &session);
108    log::debug!("{}", auth_response.detail);
109    log::debug!("Track requested: {}", &info.file);
110    let filepath = Path::new(&config.media_source).join(&info.file);
111    log::debug!("Track file lookup: {}", &filepath.to_string_lossy());
112    match std::fs::read_to_string(&filepath) {
113        Ok(content) => HttpResponse::Ok()
114            .content_type("text/plain")
115            .body(content),
116        Err(_) => squire::custom::error(
117            "CONTENT UNAVAILABLE",
118            template.get_template("error").unwrap(),
119            &metadata.pkg_version,
120            format!("'{}' was not found", &info.file),
121            StatusCode::NOT_FOUND
122        )
123    }
124}
125
126/// Create an `HttpResponse` based on the context built and rendered template.
127///
128/// # Arguments
129///
130/// * `landing` - `Template` retrieved from the configuration container.
131/// * `serializable` - `HashMap` that can be serialized into a single block of String to be rendered.
132fn render_content(landing: minijinja::Template,
133                  serializable: HashMap<&str, &String>) -> HttpResponse {
134    return match landing.render(serializable) {
135        Ok(response_body) => {
136            HttpResponse::build(StatusCode::OK)
137                .content_type("text/html; charset=utf-8").body(response_body)
138        }
139        Err(err) => {
140            log::error!("{}", err);
141            HttpResponse::FailedDependency().json("Failed to render content.")
142        }
143    };
144}
145
146/// Handles requests for the `/stream/{media_path:.*}` endpoint, serving media files and directories.
147///
148/// # Arguments
149///
150/// * `request` - A reference to the Actix web `HttpRequest` object.
151/// * `media_path` - The path parameter representing the media file or directory.
152/// * `fernet` - Fernet object to encrypt the auth payload that will be set as `session_token` cookie.
153/// * `session` - Session struct that holds the `session_mapping` and `session_tracker` to handle sessions.
154/// * `metadata` - Struct containing metadata of the application.
155/// * `config` - Configuration data for the application.
156/// * `template` - Configuration container for the loaded templates.
157///
158/// # Returns
159///
160/// Returns an `HttpResponse` containing the media content or directory listing, or an error response.
161#[get("/stream/{media_path:.*}")]
162pub async fn stream(request: HttpRequest,
163                    media_path: web::Path<String>,
164                    fernet: web::Data<Arc<Fernet>>,
165                    session: web::Data<Arc<constant::Session>>,
166                    metadata: web::Data<Arc<constant::MetaData>>,
167                    config: web::Data<Arc<squire::settings::Config>>,
168                    template: web::Data<Arc<minijinja::Environment<'static>>>) -> HttpResponse {
169    let auth_response = squire::authenticator::verify_token(&request, &config, &fernet, &session);
170    if !auth_response.ok {
171        return routes::auth::failed_auth(auth_response, &config);
172    }
173    let (_host, _last_accessed) = squire::custom::log_connection(&request, &session);
174    log::debug!("{}", auth_response.detail);
175    let filepath = media_path.to_string();
176    if !squire::authenticator::verify_secure_index(&PathBuf::from(&filepath), &auth_response.username) {
177        return squire::custom::error(
178            "RESTRICTED SECTION",
179            template.get_template("error").unwrap(),
180            &metadata.pkg_version,
181            format!("This content is not accessible, as it does not belong to the user profile '{}'", auth_response.username),
182            StatusCode::FORBIDDEN
183        );
184    }
185    let secure_path = if filepath.contains(constant::SECURE_INDEX) { "true" } else { "false" };
186    let secure_flag = secure_path.to_string();
187    // True path of the media file
188    let __target = config.media_source.join(&filepath);
189    if !__target.exists() {
190        return squire::custom::error(
191            "CONTENT UNAVAILABLE",
192            template.get_template("error").unwrap(),
193            &metadata.pkg_version,
194            format!("'{}' was not found", filepath),
195            StatusCode::NOT_FOUND
196        )
197    }
198    // True path of the media file as a String
199    let __target_str = __target.to_string_lossy().to_string();
200    let __filename = __target.file_name().unwrap().to_string_lossy().to_string();
201    if __target.is_file() {
202        let landing = template.get_template("landing").unwrap();
203        let rust_iter = squire::content::get_iter(&__target, &config.file_formats);
204        let render_path = format!("/media?file={}", url_encode(&filepath));
205        let prev = rust_iter.previous.unwrap_or_default();
206        let next = rust_iter.next.unwrap_or_default();
207        let secure_index = constant::SECURE_INDEX.to_string();
208        let mut context_builder = vec![
209            ("version", &metadata.pkg_version),
210            ("media_title", &__filename),
211            ("path", &render_path),
212            ("previous", &prev),
213            ("next", &next),
214            ("user", &auth_response.username),
215            ("secure_index", &secure_index),
216        ].into_iter().collect::<HashMap<_, _>>();
217        if constant::IMAGE_FORMATS
218            .contains(&render_path.split('.')
219                .last()
220                .unwrap()  // file extension WILL be present at this point
221                .to_lowercase().as_str()) {
222            context_builder.insert("render_image", &render_path);
223            return render_content(landing, context_builder);
224        }
225        let subtitle = subtitles(__target, &filepath);
226        let mut sfx_file = String::new();
227        if subtitle.vtt.exists() {
228            sfx_file = format!("/track?file={}", url_encode(&subtitle.vtt_file));
229        } else if subtitle.srt.exists() {
230            log::info!("Converting {:?} to {:?} for subtitles",
231                subtitle.srt.file_name().unwrap(),
232                subtitle.vtt.file_name().unwrap());
233            match squire::subtitles::srt_to_vtt(&subtitle.srt) {
234                Ok(_) => {
235                    log::debug!("Successfully converted srt to vtt file");
236                    sfx_file = format!("/track?file={}", url_encode(&subtitle.vtt_file));
237                }
238                Err(err) => log::error!("Failed to convert srt to vtt: {}", err),
239            }
240        }
241        if !sfx_file.is_empty() {
242            context_builder.insert("track", &sfx_file);
243        }
244        return render_content(landing, context_builder);
245    } else if __target.is_dir() {
246        let child_dir = __target.iter().last().unwrap().to_string_lossy().to_string();
247        let listing_page = squire::content::get_dir_stream_content(&__target_str, &child_dir, &config.file_formats);
248        let listing = template.get_template("listing").unwrap();
249        let custom_title = if child_dir.ends_with(constant::SECURE_INDEX) {
250            format!(
251                "<i class='fa-solid fa-lock'></i>&nbsp;&nbsp;{}",
252                child_dir.strip_suffix(&format!("_{}", constant::SECURE_INDEX)).unwrap()
253            )
254        } else {
255            child_dir
256        };
257        return HttpResponse::build(StatusCode::OK)
258            .content_type("text/html; charset=utf-8")
259            .body(listing.render(minijinja::context!(
260                version => metadata.pkg_version,
261                custom_title => custom_title,
262                files => listing_page.files,
263                user => auth_response.username,
264                secure_index => constant::SECURE_INDEX,
265                directories => listing_page.directories,
266                secured_directories => listing_page.secured_directories,
267                secure_path => &secure_flag
268            )).unwrap());
269    }
270    log::error!("Something went horribly wrong");
271    log::error!("Media Path: {}", filepath);
272    log::error!("Target: {}", __target_str);
273    HttpResponse::ExpectationFailed().json(routes::auth::DetailError {
274        detail: format!("'{}' was neither a file nor a folder", filepath)
275    })
276}
277
278/// Handles requests for the `/media` endpoint, serving media content for streaming.
279///
280/// # Arguments
281///
282/// * `request` - A reference to the Actix web `HttpRequest` object.
283/// * `info` - The query parameter containing the file information.
284/// * `fernet` - Fernet object to encrypt the auth payload that will be set as `session_token` cookie.
285/// * `session` - Session struct that holds the `session_mapping` and `session_tracker` to handle sessions.
286/// * `metadata` - Struct containing metadata of the application.
287/// * `config` - Configuration data for the application.
288/// * `template` - Configuration container for the loaded templates.
289///
290/// # Returns
291///
292/// Returns an `HttpResponse` containing the media content or an error response.
293#[get("/media")]
294pub async fn streaming_endpoint(request: HttpRequest,
295                                info: web::Query<Payload>,
296                                fernet: web::Data<Arc<Fernet>>,
297                                session: web::Data<Arc<constant::Session>>,
298                                metadata: web::Data<Arc<constant::MetaData>>,
299                                config: web::Data<Arc<squire::settings::Config>>,
300                                template: web::Data<Arc<minijinja::Environment<'static>>>) -> HttpResponse {
301    let auth_response = squire::authenticator::verify_token(&request, &config, &fernet, &session);
302    if !auth_response.ok {
303        return routes::auth::failed_auth(auth_response, &config);
304    }
305    let media_path = config.media_source.join(&info.file);
306    if !squire::authenticator::verify_secure_index(&media_path, &auth_response.username) {
307        return squire::custom::error(
308            "RESTRICTED SECTION",
309            template.get_template("error").unwrap(),
310            &metadata.pkg_version,
311            format!("This content is not accessible, as it does not belong to the user profile '{}'", auth_response.username),
312            StatusCode::FORBIDDEN
313        );
314    }
315    let (host, _last_accessed) = squire::custom::log_connection(&request, &session);
316    if media_path.exists() {
317        let file = actix_files::NamedFile::open_async(media_path).await.unwrap();
318        // Check if the host is making a continued connection streaming the same file
319        let mut tracker = session.tracker.lock().unwrap();
320        if tracker.get(&host).unwrap() != &info.file {
321            log::info!("Streaming {}", info.file);
322            tracker.insert(host, info.file.to_string());
323        }
324        return file.into_response(&request);
325    }
326    let error = format!("File {:?} not found", media_path);
327    log::error!("{}", error);
328    squire::custom::error(
329        "CONTENT UNAVAILABLE",
330        template.get_template("error").unwrap(),
331        &metadata.pkg_version,
332        format!("'{}' was not found", &info.file),
333        StatusCode::NOT_FOUND
334    )
335}