pharos_native/runtime/feature_runtime/
media_input.rs1use 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
13pub(super) struct PreparedInput {
15 pub request: HttpRequest,
16 files: Vec<PathBuf>,
17 committed: bool,
18}
19impl PreparedInput {
20 pub fn commit(&mut self) {
22 self.committed = true;
23 }
24}
25impl Drop for PreparedInput {
26 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
43pub(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 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 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 #[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}