chore: preserve exif data while removing geo data 63e9af60
Steve · 2026-04-04 20:01 3 file(s) · +114 −7
Cargo.lock +13 −1
644 644
645 645
[[package]]
646 646
name = "cellar"
647 -
version = "0.1.1"
647 +
version = "0.1.2"
648 648
dependencies = [
649 649
 "andromeda-auth",
650 650
 "askama 0.15.6",
1915 1915
dependencies = [
1916 1916
 "byteorder-lite",
1917 1917
 "quick-error",
1918 +
]
1919 +
1920 +
[[package]]
1921 +
name = "img-parts"
1922 +
version = "0.3.3"
1923 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1924 +
checksum = "6b4e24cfdc6f897b582508e3c382eaf5378076898f80500a80d10d761ae85e90"
1925 +
dependencies = [
1926 +
 "bytes",
1927 +
 "crc32fast",
1928 +
 "miniz_oxide",
1918 1929
]
1919 1930
1920 1931
[[package]]
3923 3934
 "askama 0.15.6",
3924 3935
 "axum",
3925 3936
 "image",
3937 +
 "img-parts",
3926 3938
 "serde",
3927 3939
 "tokio",
3928 3940
 "tower-http",
apps/shrink/Cargo.toml +1 −0
16 16
tracing-subscriber = { workspace = true }
17 17
askama = "0.15"
18 18
image = "0.25"
19 +
img-parts = "0.3"
apps/shrink/src/server.rs +100 −6
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 {