steganography_online_codec/
lib.rs1use std::path::Path;
6
7use base64::Engine;
8use reqwest::multipart::{Form, Part};
9use serde::Deserialize;
10
11pub mod errors {
13 pub const WEBAPI_CONNECTION: i32 = -1;
15 pub const SUCCESS: i32 = 0;
17 pub const UNKNOWN: i32 = 1;
19 pub const MESSAGE_TOO_LONG: i32 = 2;
21 pub const IMAGE_TOO_BIG: i32 = 3;
23 pub const INVALID_INPUT: i32 = 4;
25 pub const INVALID_IMAGE_FORMAT: i32 = 5;
27 pub const IMAGE_MALFORMED: i32 = 6;
29 pub const INVALID_PASSWORD: i32 = 7;
31 pub const LIMIT_MESSAGE: i32 = 9;
33 pub const LIMIT_PASSWORD: i32 = 10;
35 pub const OUTPUT_FILE: i32 = 99;
37 pub const INVALID_LICENSE: i32 = 100;
39}
40
41#[derive(Debug, Clone, Deserialize)]
43pub struct LicenseInfo {
44 #[serde(rename = "activationStatus")]
45 pub activation_status: Option<bool>,
46 #[serde(rename = "userName")]
47 pub user_name: Option<String>,
48 #[serde(rename = "type")]
49 pub license_type: Option<i64>,
50 #[serde(rename = "usagesTotal")]
51 pub usages_total: Option<i64>,
52 #[serde(rename = "usagesCount")]
53 pub usages_count: Option<i64>,
54}
55
56#[derive(Debug, Clone, Deserialize)]
58pub struct LimitsInfo {
59 #[serde(rename = "maxPasswordLen")]
60 pub max_password_len: Option<i64>,
61 #[serde(rename = "maxMessageLen")]
62 pub max_message_len: Option<i64>,
63 #[serde(rename = "maxFileSize")]
64 pub max_file_size: Option<i64>,
65}
66
67#[derive(Debug, Clone)]
69pub struct CodecResult {
70 pub license: Option<LicenseInfo>,
71 pub limits: Option<LimitsInfo>,
72 pub message: Option<String>,
74}
75
76#[derive(Debug, thiserror::Error)]
77pub enum SteganographyError {
78 #[error("{error_message}")]
79 Api {
80 code: i32,
81 error_message: String,
82 raw: Option<serde_json::Value>,
83 },
84}
85
86impl SteganographyError {
87 pub fn code(&self) -> i32 {
88 match self {
89 SteganographyError::Api { code, .. } => *code,
90 }
91 }
92
93 pub fn error_message(&self) -> &str {
94 match self {
95 SteganographyError::Api { error_message, .. } => error_message,
96 }
97 }
98
99 pub fn raw(&self) -> Option<&serde_json::Value> {
100 match self {
101 SteganographyError::Api { raw, .. } => raw.as_ref(),
102 }
103 }
104}
105
106pub const DEFAULT_API_URL: &str = "https://www.pelock.com/api/steganography-online-codec/v1";
108
109pub struct SteganographyOnlineCodec {
111 api_key: Option<String>,
112 api_url: String,
113 client: reqwest::Client,
114}
115
116impl SteganographyOnlineCodec {
117 pub fn new(api_key: Option<String>) -> Self {
119 Self::with_url(api_key, DEFAULT_API_URL.to_string())
120 }
121
122 pub fn with_url(api_key: Option<String>, api_url: String) -> Self {
124 let key = api_key.filter(|s| !s.is_empty());
125 Self {
126 api_key: key,
127 api_url,
128 client: reqwest::Client::new(),
129 }
130 }
131
132 pub async fn login(&self) -> Result<CodecResult, SteganographyError> {
134 let form = self.build_form("login", |f| f);
135 self.post_request_codec_result(form).await
136 }
137
138 pub async fn encode(
140 &self,
141 input_image_path: impl AsRef<Path>,
142 message_to_hide: &str,
143 password: &str,
144 output_image_path: impl AsRef<Path>,
145 ) -> Result<CodecResult, SteganographyError> {
146 let path = input_image_path.as_ref();
147 let bytes = tokio::fs::read(path)
148 .await
149 .map_err(|e| SteganographyError::Api {
150 code: errors::INVALID_INPUT,
151 error_message: e.to_string(),
152 raw: None,
153 })?;
154 let filename = path
155 .file_name()
156 .and_then(|n| n.to_str())
157 .unwrap_or("image.bin");
158 self.encode_bytes(&bytes, filename, message_to_hide, password, output_image_path)
159 .await
160 }
161
162 pub async fn encode_bytes(
164 &self,
165 image: &[u8],
166 filename_for_upload: &str,
167 message_to_hide: &str,
168 password: &str,
169 output_image_path: impl AsRef<Path>,
170 ) -> Result<CodecResult, SteganographyError> {
171 if image.is_empty() {
172 return Err(SteganographyError::Api {
173 code: errors::INVALID_INPUT,
174 error_message: "input_image is required".to_string(),
175 raw: None,
176 });
177 }
178 if message_to_hide.is_empty() {
179 return Err(SteganographyError::Api {
180 code: errors::INVALID_INPUT,
181 error_message: "message_to_hide is required".to_string(),
182 raw: None,
183 });
184 }
185 if password.is_empty() {
186 return Err(SteganographyError::Api {
187 code: errors::INVALID_INPUT,
188 error_message: "password is required".to_string(),
189 raw: None,
190 });
191 }
192 let out = output_image_path.as_ref();
193 if out.as_os_str().is_empty() {
194 return Err(SteganographyError::Api {
195 code: errors::INVALID_INPUT,
196 error_message: "output_image_path is required".to_string(),
197 raw: None,
198 });
199 }
200
201 let image_part = Part::bytes(image.to_vec())
202 .file_name(filename_for_upload.to_string())
203 .mime_str("application/octet-stream")
204 .map_err(|e| SteganographyError::Api {
205 code: errors::UNKNOWN,
206 error_message: e.to_string(),
207 raw: None,
208 })?;
209
210 let form = self.build_form("encode", |f| {
211 f.text("message", message_to_hide.to_string())
212 .text("password", password.to_string())
213 .part("image", image_part)
214 });
215
216 let json = self.post_request_json(form).await?;
217 let encoded_b64 = json
218 .get("encodedImage")
219 .and_then(|v| v.as_str())
220 .ok_or_else(|| SteganographyError::Api {
221 code: errors::UNKNOWN,
222 error_message: "Malformed API response: missing encodedImage".to_string(),
223 raw: Some(json.clone()),
224 })?;
225
226 let decoded = base64::engine::general_purpose::STANDARD
227 .decode(encoded_b64)
228 .map_err(|e| SteganographyError::Api {
229 code: errors::UNKNOWN,
230 error_message: e.to_string(),
231 raw: Some(json.clone()),
232 })?;
233
234 tokio::fs::write(output_image_path.as_ref(), &decoded)
235 .await
236 .map_err(|e| SteganographyError::Api {
237 code: errors::OUTPUT_FILE,
238 error_message: e.to_string(),
239 raw: Some(json.clone()),
240 })?;
241
242 Self::json_to_codec_result(json)
243 }
244
245 pub async fn decode(
247 &self,
248 input_image_path: impl AsRef<Path>,
249 password: &str,
250 ) -> Result<CodecResult, SteganographyError> {
251 let path = input_image_path.as_ref();
252 let bytes = tokio::fs::read(path)
253 .await
254 .map_err(|e| SteganographyError::Api {
255 code: errors::INVALID_INPUT,
256 error_message: e.to_string(),
257 raw: None,
258 })?;
259 let filename = path
260 .file_name()
261 .and_then(|n| n.to_str())
262 .unwrap_or("image.png");
263 self.decode_bytes(&bytes, filename, password).await
264 }
265
266 pub async fn decode_bytes(
268 &self,
269 image: &[u8],
270 filename_for_upload: &str,
271 password: &str,
272 ) -> Result<CodecResult, SteganographyError> {
273 if image.is_empty() {
274 return Err(SteganographyError::Api {
275 code: errors::INVALID_INPUT,
276 error_message: "input_image is required".to_string(),
277 raw: None,
278 });
279 }
280 if password.is_empty() {
281 return Err(SteganographyError::Api {
282 code: errors::INVALID_INPUT,
283 error_message: "password is required".to_string(),
284 raw: None,
285 });
286 }
287
288 let image_part = Part::bytes(image.to_vec())
289 .file_name(filename_for_upload.to_string())
290 .mime_str("application/octet-stream")
291 .map_err(|e| SteganographyError::Api {
292 code: errors::UNKNOWN,
293 error_message: e.to_string(),
294 raw: None,
295 })?;
296
297 let form = self.build_form("decode", |f| {
298 f.text("password", password.to_string())
299 .part("image", image_part)
300 });
301
302 self.post_request_codec_result(form).await
303 }
304
305 pub fn convert_size(size_bytes: i64) -> String {
307 Self::convert_size_f64(size_bytes as f64)
308 }
309
310 fn convert_size_f64(size_bytes: f64) -> String {
311 if !size_bytes.is_finite() || size_bytes < 0.0 {
312 return "0 bytes".to_string();
313 }
314 if size_bytes == 0.0 {
315 return "0 bytes".to_string();
316 }
317
318 const NAMES: &[&str] = &[
319 "bytes", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB",
320 ];
321 let i = ((size_bytes.ln() / 1024_f64.ln()).floor() as usize).min(NAMES.len() - 1);
322 let p = 1024_f64.powi(i as i32);
323 let s = ((size_bytes / p) * 100.0).round() / 100.0;
324 format!("{} {}", s, NAMES[i])
325 }
326
327 fn key_field(&self) -> String {
328 self.api_key.clone().unwrap_or_default()
329 }
330
331 fn build_form(&self, command: &'static str, extend: impl FnOnce(Form) -> Form) -> Form {
332 let base = Form::new()
333 .text("key", self.key_field())
334 .text("command", command.to_string());
335 extend(base)
336 }
337
338 async fn post_request_json(&self, form: Form) -> Result<serde_json::Value, SteganographyError> {
339 let response = self
340 .client
341 .post(&self.api_url)
342 .multipart(form)
343 .send()
344 .await
345 .map_err(|e| SteganographyError::Api {
346 code: errors::WEBAPI_CONNECTION,
347 error_message: e.to_string(),
348 raw: None,
349 })?;
350
351 if !response.status().is_success() {
352 return Err(SteganographyError::Api {
353 code: errors::WEBAPI_CONNECTION,
354 error_message: format!("HTTP {}", response.status()),
355 raw: None,
356 });
357 }
358
359 let json: serde_json::Value = response.json().await.map_err(|e| {
360 SteganographyError::Api {
361 code: errors::WEBAPI_CONNECTION,
362 error_message: e.to_string(),
363 raw: None,
364 }
365 })?;
366
367 let err_code = json
368 .get("error")
369 .and_then(|v| v.as_i64())
370 .map(|v| v as i32);
371
372 let Some(code) = err_code else {
373 return Err(SteganographyError::Api {
374 code: errors::UNKNOWN,
375 error_message: "Malformed API response: missing error field".to_string(),
376 raw: Some(json),
377 });
378 };
379
380 if code == errors::SUCCESS {
381 return Ok(json);
382 }
383
384 let msg = json
385 .get("error_message")
386 .and_then(|v| v.as_str())
387 .unwrap_or("API error")
388 .to_string();
389
390 Err(SteganographyError::Api {
391 code,
392 error_message: msg,
393 raw: Some(json),
394 })
395 }
396
397 async fn post_request_codec_result(
398 &self,
399 form: Form,
400 ) -> Result<CodecResult, SteganographyError> {
401 let json = self.post_request_json(form).await?;
402 Self::json_to_codec_result(json)
403 }
404
405 fn json_to_codec_result(json: serde_json::Value) -> Result<CodecResult, SteganographyError> {
406 let license = json
407 .get("license")
408 .cloned()
409 .and_then(|v| serde_json::from_value::<LicenseInfo>(v).ok());
410 let limits = json
411 .get("limits")
412 .cloned()
413 .and_then(|v| serde_json::from_value::<LimitsInfo>(v).ok());
414 let message = json
415 .get("message")
416 .and_then(|v| v.as_str())
417 .map(|s| s.to_string());
418
419 Ok(CodecResult {
420 license,
421 limits,
422 message,
423 })
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn convert_size_matches_js_style() {
433 assert_eq!(SteganographyOnlineCodec::convert_size(0), "0 bytes");
434 assert_eq!(SteganographyOnlineCodec::convert_size(500), "500 bytes");
435 }
436}