| 7 |
7 |
|
routing::{get, post}, |
| 8 |
8 |
|
}; |
| 9 |
9 |
|
use axum::extract::DefaultBodyLimit; |
|
10 |
+ |
use img_parts::ImageEXIF; |
|
11 |
+ |
use img_parts::jpeg::Jpeg; |
| 10 |
12 |
|
use tower_http::services::ServeDir; |
| 11 |
13 |
|
|
| 12 |
14 |
|
#[derive(Template)] |
|
| 64 |
66 |
|
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read width: {}", e)))?; |
| 65 |
67 |
|
width = text.parse::<u32>().unwrap_or(0); |
| 66 |
68 |
|
} |
| 67 |
|
- |
_ => {} |
|
69 |
+ |
_ => {} |
| 68 |
70 |
|
} |
| 69 |
71 |
|
} |
| 70 |
72 |
|
|
| 71 |
73 |
|
let file_data = file_data.ok_or((StatusCode::BAD_REQUEST, "No file provided".to_string()))?; |
| 72 |
74 |
|
|
| 73 |
|
- |
let result = tokio::task::spawn_blocking(move || compress_image(&file_data, quality, width)) |
| 74 |
|
- |
.await |
| 75 |
|
- |
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Task failed: {}", e)))? |
| 76 |
|
- |
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Compression failed: {}", e)))?; |
|
75 |
+ |
let result = |
|
76 |
+ |
tokio::task::spawn_blocking(move || compress_image(&file_data, quality, width)) |
|
77 |
+ |
.await |
|
78 |
+ |
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Task failed: {}", e)))? |
|
79 |
+ |
.map_err(|e| { |
|
80 |
+ |
( |
|
81 |
+ |
StatusCode::INTERNAL_SERVER_ERROR, |
|
82 |
+ |
format!("Compression failed: {}", e), |
|
83 |
+ |
) |
|
84 |
+ |
})?; |
| 77 |
85 |
|
|
| 78 |
86 |
|
let download_name = build_download_filename(&original_filename, "jpg"); |
| 79 |
87 |
|
|
|
| 92 |
100 |
|
} |
| 93 |
101 |
|
|
| 94 |
102 |
|
fn compress_image(data: &[u8], quality: u8, width: u32) -> Result<Vec<u8>, String> { |
|
103 |
+ |
// Extract EXIF from original before re-encoding destroys it |
|
104 |
+ |
let original_exif = Jpeg::from_bytes(data.to_vec().into()) |
|
105 |
+ |
.ok() |
|
106 |
+ |
.and_then(|j| j.exif().map(|e| e.to_vec())); |
|
107 |
+ |
|
| 95 |
108 |
|
let img = |
| 96 |
109 |
|
image::load_from_memory(data).map_err(|e| format!("Failed to decode image: {}", e))?; |
| 97 |
110 |
|
|
|
| 108 |
121 |
|
img.write_with_encoder(encoder) |
| 109 |
122 |
|
.map_err(|e| format!("JPEG encoding failed: {}", e))?; |
| 110 |
123 |
|
|
| 111 |
|
- |
Ok(output) |
|
124 |
+ |
// Re-inject EXIF into the compressed output (always strip GPS data) |
|
125 |
+ |
if let Some(exif_bytes) = original_exif { |
|
126 |
+ |
let exif = strip_gps_from_exif(&exif_bytes); |
|
127 |
+ |
|
|
128 |
+ |
let mut out_jpeg = Jpeg::from_bytes(output.into()) |
|
129 |
+ |
.map_err(|e| format!("Failed to parse compressed JPEG: {}", e))?; |
|
130 |
+ |
out_jpeg.set_exif(Some(exif.into())); |
|
131 |
+ |
let mut final_output = Vec::new(); |
|
132 |
+ |
out_jpeg |
|
133 |
+ |
.encoder() |
|
134 |
+ |
.write_to(&mut final_output) |
|
135 |
+ |
.map_err(|e| format!("Failed to write JPEG with EXIF: {}", e))?; |
|
136 |
+ |
Ok(final_output) |
|
137 |
+ |
} else { |
|
138 |
+ |
Ok(output) |
|
139 |
+ |
} |
|
140 |
+ |
} |
|
141 |
+ |
|
|
142 |
+ |
/// Strips GPS data from raw EXIF bytes by zeroing the GPS IFD entry count. |
|
143 |
+ |
/// Preserves all other metadata (camera, lens, settings, etc.) and offsets. |
|
144 |
+ |
fn strip_gps_from_exif(exif: &[u8]) -> Vec<u8> { |
|
145 |
+ |
let mut data = exif.to_vec(); |
|
146 |
+ |
|
|
147 |
+ |
// img-parts strips the "Exif\0\0" prefix, so bytes start with TIFF header (II/MM) |
|
148 |
+ |
let tiff_start = if data.len() >= 14 && &data[0..4] == b"Exif" { |
|
149 |
+ |
6 |
|
150 |
+ |
} else if data.len() >= 8 && (&data[0..2] == b"II" || &data[0..2] == b"MM") { |
|
151 |
+ |
0 |
|
152 |
+ |
} else { |
|
153 |
+ |
return data; |
|
154 |
+ |
}; |
|
155 |
+ |
let big_endian = &data[tiff_start..tiff_start + 2] == b"MM"; |
|
156 |
+ |
|
|
157 |
+ |
let read_u16 = |d: &[u8], off: usize| -> u16 { |
|
158 |
+ |
if big_endian { |
|
159 |
+ |
u16::from_be_bytes([d[off], d[off + 1]]) |
|
160 |
+ |
} else { |
|
161 |
+ |
u16::from_le_bytes([d[off], d[off + 1]]) |
|
162 |
+ |
} |
|
163 |
+ |
}; |
|
164 |
+ |
|
|
165 |
+ |
let read_u32 = |d: &[u8], off: usize| -> u32 { |
|
166 |
+ |
if big_endian { |
|
167 |
+ |
u32::from_be_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]]) |
|
168 |
+ |
} else { |
|
169 |
+ |
u32::from_le_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]]) |
|
170 |
+ |
} |
|
171 |
+ |
}; |
|
172 |
+ |
|
|
173 |
+ |
// IFD0 offset (relative to TIFF start) |
|
174 |
+ |
let ifd0_rel = read_u32(&data, tiff_start + 4) as usize; |
|
175 |
+ |
let ifd0_off = tiff_start + ifd0_rel; |
|
176 |
+ |
if ifd0_off + 2 > data.len() { |
|
177 |
+ |
return data; |
|
178 |
+ |
} |
|
179 |
+ |
|
|
180 |
+ |
let entry_count = read_u16(&data, ifd0_off) as usize; |
|
181 |
+ |
|
|
182 |
+ |
for i in 0..entry_count { |
|
183 |
+ |
let entry_off = ifd0_off + 2 + i * 12; |
|
184 |
+ |
if entry_off + 12 > data.len() { |
|
185 |
+ |
break; |
|
186 |
+ |
} |
|
187 |
+ |
let tag = read_u16(&data, entry_off); |
|
188 |
+ |
if tag == 0x8825 { |
|
189 |
+ |
// GPS IFD pointer — read the offset, then zero out the GPS IFD entry count |
|
190 |
+ |
let gps_ifd_rel = read_u32(&data, entry_off + 8) as usize; |
|
191 |
+ |
let gps_ifd_off = tiff_start + gps_ifd_rel; |
|
192 |
+ |
if gps_ifd_off + 2 <= data.len() { |
|
193 |
+ |
let zero = if big_endian { |
|
194 |
+ |
0u16.to_be_bytes() |
|
195 |
+ |
} else { |
|
196 |
+ |
0u16.to_le_bytes() |
|
197 |
+ |
}; |
|
198 |
+ |
data[gps_ifd_off] = zero[0]; |
|
199 |
+ |
data[gps_ifd_off + 1] = zero[1]; |
|
200 |
+ |
} |
|
201 |
+ |
break; |
|
202 |
+ |
} |
|
203 |
+ |
} |
|
204 |
+ |
|
|
205 |
+ |
data |
| 112 |
206 |
|
} |
| 113 |
207 |
|
|
| 114 |
208 |
|
fn build_download_filename(original: &str, new_ext: &str) -> String { |