Skip to main content

pharos_native/runtime/feature_runtime/
media_input.rs

1//! Bounded image parts for opt-in declarative actions, stored outside app artifacts.
2use super::{FeatureRuntimeError, artifact_root};
3use crate::{
4    config::AppRuntime,
5    runtime::{config, dynamic, fs, http::HttpRequest, json_commands},
6};
7use serde_json::{Map, Value};
8use std::{collections::BTreeSet, io::Read, path::PathBuf};
9
10const IMAGE_LIMIT: usize = 2 * 1024 * 1024;
11const BODY_LIMIT: usize = IMAGE_LIMIT + 128 * 1024;
12
13/// Owns newly stored media until the associated database transaction succeeds.
14pub(super) struct PreparedInput {
15    pub request: HttpRequest,
16    files: Vec<PathBuf>,
17    committed: bool,
18}
19impl PreparedInput {
20    /// Retains media once its declared database mutation has committed.
21    pub fn commit(&mut self) {
22        self.committed = true;
23    }
24}
25impl Drop for PreparedInput {
26    /// Removes only this request's files if input validation or its transaction fails.
27    fn drop(&mut self) {
28        if !self.committed {
29            for file in &self.files {
30                if let Err(error) = std::fs::remove_file(file) {
31                    eprintln!("uncommitted image cleanup failed: {error}");
32                }
33                if let Some(parent) = file.parent()
34                    && let Err(error) = std::fs::remove_dir(parent)
35                {
36                    eprintln!("uncommitted image directory cleanup failed: {error}");
37                }
38            }
39        }
40    }
41}
42
43/// Converts declared multipart fields to ordinary validated scalar inputs.
44pub(super) fn prepare(
45    app: &AppRuntime,
46    request: &HttpRequest,
47    contract: &Value,
48) -> Result<PreparedInput, FeatureRuntimeError> {
49    let mut prepared = PreparedInput {
50        request: request.clone(),
51        files: Vec::new(),
52        committed: false,
53    };
54    let content_type = request.content_type.as_deref().unwrap_or("");
55    if !content_type.starts_with("multipart/form-data") {
56        return Ok(prepared);
57    }
58    let invalid = || {
59        FeatureRuntimeError::InvalidInput(
60            "image upload: choose one PNG, JPEG, GIF or WebP image up to 2 MiB".to_owned(),
61        )
62    };
63    let fields = contract
64        .get("inline_fields")
65        .and_then(Value::as_object)
66        .ok_or_else(invalid)?;
67    let uploads: Vec<_> = fields
68        .iter()
69        .filter_map(|(name, rules)| {
70            rules
71                .get("image_upload")
72                .and_then(Value::as_str)
73                .map(|file| (name, file))
74        })
75        .collect();
76    if uploads.len() != 1 || fields.len() > 32 {
77        return Err(invalid());
78    }
79    let (target, file_field) = uploads[0];
80    super::sql_identifier(file_field)?;
81    if fields.contains_key(file_field) {
82        return Err(invalid());
83    }
84    let mut body = Vec::new();
85    if let Some(path) = request.body_file_path.as_deref() {
86        std::fs::File::open(path)
87            .map_err(|_| invalid())?
88            .take((BODY_LIMIT + 1) as u64)
89            .read_to_end(&mut body)
90            .map_err(|_| invalid())?;
91    } else {
92        if request.body.len() > BODY_LIMIT {
93            return Err(invalid());
94        }
95        body.extend_from_slice(&request.body);
96    }
97    if body.len() > BODY_LIMIT {
98        return Err(invalid());
99    }
100    let parts = json_commands::request_multipart_named_parts(content_type, &body, 34, BODY_LIMIT);
101    if parts.is_empty() {
102        return Err(invalid());
103    }
104    let mut seen = BTreeSet::new();
105    let mut values = Map::new();
106    let mut image = None;
107    for (name, filename, bytes) in parts {
108        if !seen.insert(name.clone()) {
109            return Err(invalid());
110        }
111        if name == file_field {
112            if filename.as_deref().unwrap_or("").is_empty() && bytes.is_empty() {
113                continue;
114            }
115            let filename = filename.ok_or_else(invalid)?;
116            if bytes.is_empty() || bytes.len() > IMAGE_LIMIT {
117                return Err(invalid());
118            }
119            // Reuse the reviewed image-signature validator; SVG and extension spoofing fail closed.
120            pharos_runtime_architect::image_assets::content_type(&filename, &bytes)
121                .map_err(|_| invalid())?;
122            image = Some((filename, bytes));
123        } else {
124            if filename.is_some() || (name != "csrf_token" && !fields.contains_key(&name)) {
125                return Err(invalid());
126            }
127            if name != "csrf_token" {
128                values.insert(
129                    name,
130                    Value::String(String::from_utf8(bytes).map_err(|_| invalid())?),
131                );
132            }
133        }
134    }
135    if let Some((filename, bytes)) = image {
136        let artifact = artifact_root(app)?;
137        let root = config::app_media_root(&artifact).ok_or_else(invalid)?;
138        if root.starts_with(&artifact) {
139            return Err(invalid());
140        }
141        let safe = dynamic::safe_upload_filename(&filename)
142            .filter(|name| !name.is_empty() && name.len() <= 200)
143            .ok_or_else(invalid)?;
144        let nonce = dynamic::random_secret_hex(16).map_err(|_| invalid())?;
145        let relative = format!("images/{nonce}/{safe}");
146        let path = root.join(&relative);
147        fs::write_file_within(&root, &path, &bytes, 0o640).map_err(|_| invalid())?;
148        prepared.files.push(path);
149        values.insert(
150            target.clone(),
151            Value::String(format!(
152                "{}/media/{relative}",
153                app.base_path.as_deref().unwrap_or("").trim_end_matches('/')
154            )),
155        );
156    }
157    prepared.request.body = serde_json::to_vec(&values).map_err(|_| invalid())?;
158    prepared.request.body_len = prepared.request.body.len();
159    prepared.request.body_file_path = None;
160    prepared.request.content_type = Some("application/json".to_owned());
161    prepared.request.is_multipart = false;
162    Ok(prepared)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use serde_json::json;
169
170    /// Builds one bounded multipart request with a named image and preserved form fields.
171    fn request(filename: &str, bytes: &[u8], extra: &str) -> HttpRequest {
172        let mut body = format!("--media-test\r\nContent-Disposition: form-data; name=\"image_static_url\"\r\n\r\n\r\n--media-test\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nArtist\r\n{extra}--media-test\r\nContent-Disposition: form-data; name=\"artist_image\"; filename=\"{filename}\"\r\nContent-Type: image/png\r\n\r\n").into_bytes();
173        body.extend_from_slice(bytes);
174        body.extend_from_slice(b"\r\n--media-test--\r\n");
175        HttpRequest {
176            method: "POST".to_owned(),
177            content_type: Some("multipart/form-data; boundary=media-test".to_owned()),
178            body_len: body.len(),
179            body,
180            is_multipart: true,
181            ..Default::default()
182        }
183    }
184
185    /// Proves uploads are opt-in, bounded, signature checked, isolated and cleaned on failed saves.
186    #[test]
187    fn declared_image_upload_is_bounded_and_transaction_scoped() {
188        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
189            .join("tmp")
190            .join(format!(
191                "pharos-image-action-{}",
192                dynamic::random_secret_hex(8).unwrap()
193            ));
194        let artifact = root.join("app");
195        let media = root.join("media");
196        std::fs::create_dir_all(&artifact).unwrap();
197        std::fs::write(
198            artifact.join("app.conf"),
199            format!("MEDIA_ROOT={}\n", media.display()),
200        )
201        .unwrap();
202        let app = AppRuntime {
203            artifact_dir: Some(artifact.to_string_lossy().into_owned()),
204            ..Default::default()
205        };
206        let contract = json!({"inline_fields":{"image_static_url":{"type":"string","max_length":512,"image_upload":"artist_image"},"name":{"type":"string","required":true,"max_length":100}}});
207        let png = b"\x89PNG\r\n\x1a\n";
208        let valid = request("portrait.png", png, "");
209        let mut staged = prepare(&app, &valid, &contract).unwrap();
210        let values =
211            super::super::validated_action_input(&staged.request, &contract, None, "save").unwrap();
212        assert_eq!(values["name"], "Artist");
213        let url = values["image_static_url"].as_str().unwrap();
214        assert!(url.starts_with("/media/images/") && url.ends_with("/portrait.png"));
215        let path = media.join(url.strip_prefix("/media/").unwrap());
216        assert_eq!(std::fs::read(&path).unwrap(), png);
217        drop(staged);
218        assert!(
219            !path.exists(),
220            "failed/uncommitted saves must not leak a file"
221        );
222        staged = prepare(&app, &valid, &contract).unwrap();
223        let committed = staged.files[0].clone();
224        staged.commit();
225        drop(staged);
226        assert!(committed.exists());
227        assert!(prepare(&app, &request("portrait.svg", png, ""), &contract).is_err());
228        assert!(
229            prepare(
230                &app,
231                &request("portrait.png", b"not an image", ""),
232                &contract
233            )
234            .is_err()
235        );
236        assert!(
237            prepare(
238                &app,
239                &request("portrait.png", &vec![0; IMAGE_LIMIT + 1], ""),
240                &contract
241            )
242            .is_err()
243        );
244        assert!(
245            prepare(
246                &app,
247                &valid,
248                &json!({"inline_fields":{"name":{"type":"string"}}})
249            )
250            .is_err()
251        );
252        let duplicate =
253            "--media-test\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nOther\r\n";
254        assert!(prepare(&app, &request("portrait.png", png, duplicate), &contract).is_err());
255        let unknown =
256            "--media-test\r\nContent-Disposition: form-data; name=\"unexpected\"\r\n\r\nvalue\r\n";
257        assert!(prepare(&app, &request("portrait.png", png, unknown), &contract).is_err());
258        let mut disk = valid.clone();
259        let input = root.join("request.multipart");
260        std::fs::write(&input, &disk.body).unwrap();
261        disk.body.clear();
262        disk.body_file_path = Some(input.to_string_lossy().into_owned());
263        assert!(prepare(&app, &disk, &contract).is_ok());
264        std::fs::remove_dir_all(root).unwrap();
265    }
266}