1#![cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
10
11use std::fs;
12use std::io::Read;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use anyhow::{Context, Result, anyhow, bail};
18
19use crate::original_save;
20use crate::runtime::CommandContext;
21
22const TWITTER_API_DOMAIN: &str = "api.twitter.com";
23const TWITTER_UPLOAD_DOMAIN: &str = "upload.twitter.com";
24const TWITTER_USER_AGENT: &str = "SiglusEngine/1.0.0";
25const TWITTER_SIGNATURE_METHOD: &str = "HMAC-SHA1";
26const TWITTER_OAUTH_VERSION: &str = "1.0";
27const TWITTER_STATE_FILE: &str = "twitter_state.dat";
28
29static NONCE_COUNTER: AtomicU64 = AtomicU64::new(1);
30
31#[derive(Debug, Clone, Default)]
32pub struct TwitterState {
33 pub request_token: String,
34 pub request_token_secret: String,
35 pub access_token: String,
36 pub access_token_secret: String,
37 pub user_id: String,
38 pub user_name: String,
39 pub screen_name: String,
40 pub loaded: bool,
41}
42
43impl TwitterState {
44 pub fn is_authorized(&self) -> bool {
45 !self.access_token.is_empty()
46 && !self.access_token_secret.is_empty()
47 && !self.user_id.is_empty()
48 && !self.user_name.is_empty()
49 && !self.screen_name.is_empty()
50 }
51
52 pub fn clear_authorization(&mut self) {
53 self.request_token.clear();
54 self.request_token_secret.clear();
55 self.access_token.clear();
56 self.access_token_secret.clear();
57 self.user_id.clear();
58 self.user_name.clear();
59 self.screen_name.clear();
60 }
61}
62
63#[derive(Debug, Clone)]
64pub struct TwitterDialogRequest {
65 pub image_path: PathBuf,
66 pub image_rgba: Vec<u8>,
67 pub image_width: u32,
68 pub image_height: u32,
69 pub initial_text: String,
70}
71
72fn gameexe_value(ctx: &CommandContext, key: &str) -> String {
73 ctx.tables
74 .gameexe
75 .as_ref()
76 .and_then(|cfg| cfg.get_unquoted(key))
77 .unwrap_or("")
78 .to_string()
79}
80
81fn state_path(project_dir: &Path) -> PathBuf {
82 original_save::save_dir(project_dir).join(TWITTER_STATE_FILE)
83}
84
85fn percent_encode(value: &str) -> String {
86 const HEX: &[u8; 16] = b"0123456789ABCDEF";
87 let mut result = String::with_capacity(value.len());
88 for c in value.as_bytes().iter().copied() {
89 if c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~') {
90 result.push(c as char);
91 } else {
92 result.push('%');
93 result.push(HEX[(c >> 4) as usize] as char);
94 result.push(HEX[(c & 0x0f) as usize] as char);
95 }
96 }
97 result
98}
99
100fn percent_decode(value: &str) -> String {
101 fn hex(b: u8) -> Option<u8> {
102 match b {
103 b'0'..=b'9' => Some(b - b'0'),
104 b'a'..=b'f' => Some(b - b'a' + 10),
105 b'A'..=b'F' => Some(b - b'A' + 10),
106 _ => None,
107 }
108 }
109
110 let bytes = value.as_bytes();
111 let mut out = Vec::with_capacity(bytes.len());
112 let mut i = 0;
113 while i < bytes.len() {
114 if bytes[i] == b'%'
115 && i + 2 < bytes.len()
116 && let (Some(hi), Some(lo)) = (hex(bytes[i + 1]), hex(bytes[i + 2]))
117 {
118 out.push((hi << 4) | lo);
119 i += 3;
120 continue;
121 }
122 out.push(bytes[i]);
123 i += 1;
124 }
125 String::from_utf8_lossy(&out).into_owned()
126}
127
128fn query_to_pairs(query: &str) -> Vec<(String, String)> {
129 query
132 .split('&')
133 .filter_map(|entry| {
134 let mut parts = entry.split('=');
135 let key = parts.next()?;
136 let value = parts.next()?;
137 if parts.next().is_some() {
138 return None;
139 }
140 Some((key.to_string(), value.to_string()))
141 })
142 .collect()
143}
144
145fn query_value<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
146 pairs
147 .iter()
148 .find(|(candidate, _)| candidate == key)
149 .map(|(_, value)| value.as_str())
150}
151
152fn base64_encode(data: &[u8]) -> String {
153 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
154 let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
155 let mut i = 0;
156 while i + 3 <= data.len() {
157 let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | data[i + 2] as u32;
158 out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
159 out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
160 out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
161 out.push(TABLE[(n & 0x3f) as usize] as char);
162 i += 3;
163 }
164 match data.len() - i {
165 1 => {
166 let n = (data[i] as u32) << 16;
167 out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
168 out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
169 out.push('=');
170 out.push('=');
171 }
172 2 => {
173 let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8);
174 out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
175 out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
176 out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
177 out.push('=');
178 }
179 _ => {}
180 }
181 out
182}
183
184fn sha1(data: &[u8]) -> [u8; 20] {
185 let mut h0: u32 = 0x6745_2301;
186 let mut h1: u32 = 0xefcd_ab89;
187 let mut h2: u32 = 0x98ba_dcfe;
188 let mut h3: u32 = 0x1032_5476;
189 let mut h4: u32 = 0xc3d2_e1f0;
190
191 let bit_len = (data.len() as u64).wrapping_mul(8);
192 let mut msg = Vec::with_capacity((data.len() + 9).div_ceil(64) * 64);
193 msg.extend_from_slice(data);
194 msg.push(0x80);
195 while msg.len() % 64 != 56 {
196 msg.push(0);
197 }
198 msg.extend_from_slice(&bit_len.to_be_bytes());
199
200 for block in msg.as_chunks::<64>().0.iter() {
201 let mut w = [0u32; 80];
202 for (i, word) in w.iter_mut().take(16).enumerate() {
203 let j = i * 4;
204 *word = u32::from_be_bytes([block[j], block[j + 1], block[j + 2], block[j + 3]]);
205 }
206 for i in 16..80 {
207 w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
208 }
209
210 let mut a = h0;
211 let mut b = h1;
212 let mut c = h2;
213 let mut d = h3;
214 let mut e = h4;
215
216 for (i, wi) in w.iter().copied().enumerate() {
217 let (f, k) = match i {
218 0..=19 => ((b & c) | ((!b) & d), 0x5a82_7999),
219 20..=39 => (b ^ c ^ d, 0x6ed9_eba1),
220 40..=59 => ((b & c) | (b & d) | (c & d), 0x8f1b_bcdc),
221 _ => (b ^ c ^ d, 0xca62_c1d6),
222 };
223 let temp = a
224 .rotate_left(5)
225 .wrapping_add(f)
226 .wrapping_add(e)
227 .wrapping_add(k)
228 .wrapping_add(wi);
229 e = d;
230 d = c;
231 c = b.rotate_left(30);
232 b = a;
233 a = temp;
234 }
235
236 h0 = h0.wrapping_add(a);
237 h1 = h1.wrapping_add(b);
238 h2 = h2.wrapping_add(c);
239 h3 = h3.wrapping_add(d);
240 h4 = h4.wrapping_add(e);
241 }
242
243 let mut out = [0u8; 20];
244 out[0..4].copy_from_slice(&h0.to_be_bytes());
245 out[4..8].copy_from_slice(&h1.to_be_bytes());
246 out[8..12].copy_from_slice(&h2.to_be_bytes());
247 out[12..16].copy_from_slice(&h3.to_be_bytes());
248 out[16..20].copy_from_slice(&h4.to_be_bytes());
249 out
250}
251
252fn hmac_sha1(key: &[u8], data: &[u8]) -> [u8; 20] {
253 const BLOCK: usize = 64;
254 let mut key_block = [0u8; BLOCK];
255 if key.len() > BLOCK {
256 key_block[..20].copy_from_slice(&sha1(key));
257 } else {
258 key_block[..key.len()].copy_from_slice(key);
259 }
260
261 let mut inner = Vec::with_capacity(BLOCK + data.len());
262 let mut outer = Vec::with_capacity(BLOCK + 20);
263 for b in key_block {
264 inner.push(b ^ 0x36);
265 outer.push(b ^ 0x5c);
266 }
267 inner.extend_from_slice(data);
268 let inner_digest = sha1(&inner);
269 outer.extend_from_slice(&inner_digest);
270 sha1(&outer)
271}
272
273fn hmac_base64_encode(key: &str, data: &str) -> String {
274 base64_encode(&hmac_sha1(key.as_bytes(), data.as_bytes()))
275}
276
277fn oauth_timestamp() -> String {
278 SystemTime::now()
279 .duration_since(UNIX_EPOCH)
280 .unwrap_or_default()
281 .as_secs()
282 .to_string()
283}
284
285fn oauth_nonce() -> String {
286 let now = SystemTime::now()
287 .duration_since(UNIX_EPOCH)
288 .unwrap_or_default()
289 .as_nanos() as u64;
290 let seq = NONCE_COUNTER.fetch_add(1, Ordering::Relaxed);
291 now.wrapping_add(seq).to_string()
292}
293
294fn oauth_params(consumer_key: &str, token: Option<&str>) -> Vec<(String, String)> {
295 let mut params = vec![("oauth_consumer_key".to_string(), consumer_key.to_string())];
296 if let Some(token) = token {
297 params.push(("oauth_token".to_string(), token.to_string()));
298 }
299 params.push((
300 "oauth_signature_method".to_string(),
301 TWITTER_SIGNATURE_METHOD.to_string(),
302 ));
303 params.push(("oauth_timestamp".to_string(), oauth_timestamp()));
304 params.push(("oauth_nonce".to_string(), oauth_nonce()));
305 params.push((
306 "oauth_version".to_string(),
307 TWITTER_OAUTH_VERSION.to_string(),
308 ));
309 params
310}
311
312fn twitter_request(
313 request_page: &str,
314 domain: &str,
315 request_method: &str,
316 request_params: &[(String, String)],
317 oauth_params: &[(String, String)],
318 extra_headers: &[(&str, String)],
319 body: &[u8],
320 consumer_secret: &str,
321 token_secret: &str,
322) -> Result<Vec<u8>> {
323 let request_address = format!("https://{domain}{request_page}");
324
325 let mut authorization_params =
330 Vec::with_capacity(request_params.len() + oauth_params.len() + 1);
331 authorization_params.extend_from_slice(request_params);
332 authorization_params.extend_from_slice(oauth_params);
333 authorization_params.sort_by(|a, b| a.0.cmp(&b.0));
334
335 let signature_param = authorization_params
336 .iter()
337 .map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
338 .collect::<Vec<_>>()
339 .join("&");
340 let signature_key = format!(
341 "{}&{}",
342 percent_encode(consumer_secret),
343 percent_encode(token_secret)
344 );
345 let signature_value = format!(
346 "{}&{}&{}",
347 percent_encode(request_method),
348 percent_encode(&request_address),
349 percent_encode(&signature_param)
350 );
351 let signature = hmac_base64_encode(&signature_key, &signature_value);
352 authorization_params.push(("oauth_signature".to_string(), signature));
353
354 let auth_header = format!(
355 "OAuth {}",
356 authorization_params
357 .iter()
358 .map(|(key, value)| format!("{}=\"{}\"", percent_encode(key), percent_encode(value)))
359 .collect::<Vec<_>>()
360 .join(",")
361 );
362
363 let query = request_params
364 .iter()
365 .map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
366 .collect::<Vec<_>>()
367 .join("&");
368 let url = if query.is_empty() {
369 request_address
370 } else {
371 format!("{request_address}?{query}")
372 };
373
374 let mut request = match request_method {
375 "GET" => ureq::get(&url),
376 "POST" => ureq::post(&url),
377 other => bail!("unsupported Twitter HTTP method {other}"),
378 };
379 request = request
380 .set("User-Agent", TWITTER_USER_AGENT)
381 .set("Authorization", &auth_header);
382 for (name, value) in extra_headers {
383 request = request.set(name, value);
384 }
385
386 let response = if request_method == "POST" && !body.is_empty() {
387 match request.send_bytes(body) {
388 Ok(response) => response,
389 Err(ureq::Error::Status(_, response)) => response,
390 Err(err) => return Err(anyhow!(err)).with_context(|| format!("POST {url}")),
391 }
392 } else {
393 match request.call() {
394 Ok(response) => response,
395 Err(ureq::Error::Status(_, response)) => response,
396 Err(err) => {
397 return Err(anyhow!(err)).with_context(|| format!("{request_method} {url}"));
398 }
399 }
400 };
401
402 let mut bytes = Vec::new();
403 response
404 .into_reader()
405 .read_to_end(&mut bytes)
406 .with_context(|| format!("read Twitter response from {url}"))?;
407 Ok(bytes)
411}
412
413fn persist_state(project_dir: &Path, state: &TwitterState) -> Result<()> {
414 let path = state_path(project_dir);
415 if let Some(parent) = path.parent() {
416 fs::create_dir_all(parent)
417 .with_context(|| format!("create Twitter state directory {}", parent.display()))?;
418 }
419 let values = [
420 ("oauth_access_token", state.access_token.as_str()),
421 (
422 "oauth_access_token_secret",
423 state.access_token_secret.as_str(),
424 ),
425 ("twitter_user_id", state.user_id.as_str()),
426 ("twitter_user_name", state.user_name.as_str()),
427 ("twitter_screen_name", state.screen_name.as_str()),
428 ];
429 let mut body = String::new();
430 for (key, value) in values {
431 body.push_str(key);
432 body.push('=');
433 body.push_str(&percent_encode(value));
434 body.push('\n');
435 }
436 fs::write(&path, body).with_context(|| format!("write Twitter state {}", path.display()))
437}
438
439pub fn save_state(ctx: &CommandContext) -> Result<()> {
440 persist_state(&ctx.project_dir, &ctx.globals.twitter)
441}
442
443pub fn ensure_state_loaded(ctx: &mut CommandContext) {
444 if ctx.globals.twitter.loaded {
445 return;
446 }
447 ctx.globals.twitter.loaded = true;
448 let path = state_path(&ctx.project_dir);
449 let Ok(text) = fs::read_to_string(&path) else {
450 return;
451 };
452 for line in text.lines() {
453 let Some((key, value)) = line.split_once('=') else {
454 continue;
455 };
456 let value = percent_decode(value);
457 match key {
458 "oauth_access_token" => ctx.globals.twitter.access_token = value,
459 "oauth_access_token_secret" => ctx.globals.twitter.access_token_secret = value,
460 "twitter_user_id" => ctx.globals.twitter.user_id = value,
461 "twitter_user_name" => ctx.globals.twitter.user_name = value,
462 "twitter_screen_name" => ctx.globals.twitter.screen_name = value,
463 _ => {}
464 }
465 }
466}
467
468pub fn begin_authorize(ctx: &mut CommandContext) -> Result<String> {
469 ensure_state_loaded(ctx);
470 ctx.globals.twitter.clear_authorization();
471
472 let consumer_key = gameexe_value(ctx, "TWITTER.API_KEY");
473 let consumer_secret = gameexe_value(ctx, "TWITTER.API_SECRET");
474 let callback_url = gameexe_value(ctx, "TWITTER.CALLBACK_URL");
475 if consumer_key.is_empty() || consumer_secret.is_empty() || callback_url.is_empty() {
476 bail!("Twitter API_KEY, API_SECRET and CALLBACK_URL must all be configured");
477 }
478
479 let mut oauth = oauth_params(&consumer_key, None);
480 oauth.insert(0, ("oauth_callback".to_string(), callback_url));
481 let response = twitter_request(
482 "/oauth/request_token",
483 TWITTER_API_DOMAIN,
484 "POST",
485 &[],
486 &oauth,
487 &[],
488 &[],
489 &consumer_secret,
490 "",
491 )?;
492 let response = String::from_utf8_lossy(&response);
493 let pairs = query_to_pairs(&response);
494 let request_token = query_value(&pairs, "oauth_token")
495 .ok_or_else(|| anyhow!("Twitter request-token response omitted oauth_token"))?
496 .to_string();
497 let request_secret = query_value(&pairs, "oauth_token_secret")
498 .ok_or_else(|| anyhow!("Twitter request-token response omitted oauth_token_secret"))?
499 .to_string();
500 ctx.globals.twitter.request_token = request_token.clone();
501 ctx.globals.twitter.request_token_secret = request_secret;
502
503 let auth_url = format!(
504 "https://api.twitter.com/oauth/authenticate?oauth_token={}",
505 percent_encode(&request_token)
506 );
507 ctx.net.open_url(&auth_url)?;
508 Ok(auth_url)
509}
510
511fn callback_query<'a>(configured_callback: &str, submitted: &'a str) -> Result<&'a str> {
512 let trimmed = submitted.trim();
513 if trimmed.is_empty() {
514 bail!("OAuth callback/verifier is empty");
515 }
516
517 if !configured_callback.is_empty() && trimmed.starts_with(configured_callback) {
522 let tail = trimmed
523 .strip_prefix(configured_callback)
524 .ok_or_else(|| anyhow!("OAuth callback URL does not match #TWITTER.CALLBACK_URL"))?;
525 return tail
528 .get(1..)
529 .filter(|query| !query.is_empty())
530 .ok_or_else(|| anyhow!("OAuth callback URL has no query string"));
531 }
532 if trimmed.contains("://") {
533 bail!("OAuth callback URL does not match #TWITTER.CALLBACK_URL");
534 }
535 Ok(trimmed)
536}
537
538pub fn complete_authorize(
539 ctx: &mut CommandContext,
540 submitted_callback_or_verifier: &str,
541) -> Result<()> {
542 ensure_state_loaded(ctx);
543 let consumer_key = gameexe_value(ctx, "TWITTER.API_KEY");
544 let consumer_secret = gameexe_value(ctx, "TWITTER.API_SECRET");
545 let callback_url = gameexe_value(ctx, "TWITTER.CALLBACK_URL");
546 if consumer_key.is_empty() || consumer_secret.is_empty() || callback_url.is_empty() {
547 bail!("Twitter API_KEY, API_SECRET and CALLBACK_URL must all be configured");
548 }
549 if ctx.globals.twitter.request_token.is_empty()
550 || ctx.globals.twitter.request_token_secret.is_empty()
551 {
552 bail!("Twitter request token is missing; start authentication again");
553 }
554
555 let query = callback_query(&callback_url, submitted_callback_or_verifier)?;
556 let pairs = query_to_pairs(query);
557 let bare_verifier = !query.contains('=') && !query.contains('&');
558 let verifier = query_value(&pairs, "oauth_verifier")
559 .map(str::to_string)
560 .or_else(|| bare_verifier.then(|| query.to_string()))
561 .ok_or_else(|| anyhow!("OAuth callback omitted oauth_verifier"))?;
562 let returned_token = if bare_verifier {
563 ctx.globals.twitter.request_token.clone()
564 } else {
565 query_value(&pairs, "oauth_token").unwrap_or("").to_string()
568 };
569
570 let mut oauth = oauth_params(&consumer_key, Some(&returned_token));
571 oauth.insert(2, ("oauth_verifier".to_string(), verifier));
572 let response = twitter_request(
573 "/oauth/access_token",
574 TWITTER_API_DOMAIN,
575 "POST",
576 &[],
577 &oauth,
578 &[],
579 &[],
580 &consumer_secret,
581 &ctx.globals.twitter.request_token_secret,
582 )?;
583 let response = String::from_utf8_lossy(&response);
584 let pairs = query_to_pairs(&response);
585 ctx.globals.twitter.access_token = query_value(&pairs, "oauth_token")
586 .ok_or_else(|| anyhow!("Twitter access-token response omitted oauth_token"))?
587 .to_string();
588 ctx.globals.twitter.access_token_secret = query_value(&pairs, "oauth_token_secret")
589 .ok_or_else(|| anyhow!("Twitter access-token response omitted oauth_token_secret"))?
590 .to_string();
591 ctx.globals.twitter.user_id = query_value(&pairs, "user_id")
592 .ok_or_else(|| anyhow!("Twitter access-token response omitted user_id"))?
593 .to_string();
594 ctx.globals.twitter.screen_name = query_value(&pairs, "screen_name")
595 .ok_or_else(|| anyhow!("Twitter access-token response omitted screen_name"))?
596 .to_string();
597
598 let oauth = oauth_params(&consumer_key, Some(&ctx.globals.twitter.access_token));
599 let request = vec![("user_id".to_string(), ctx.globals.twitter.user_id.clone())];
600 let response = twitter_request(
601 "/1.1/users/show.json",
602 TWITTER_API_DOMAIN,
603 "GET",
604 &request,
605 &oauth,
606 &[],
607 &[],
608 &consumer_secret,
609 &ctx.globals.twitter.access_token_secret,
610 )?;
611 let response_text = String::from_utf8_lossy(&response);
612 ctx.globals.twitter.user_name = json_string_field(&response_text, "name")
613 .ok_or_else(|| anyhow!("Twitter users/show response omitted name"))?;
614
615 ctx.globals.twitter.request_token.clear();
616 ctx.globals.twitter.request_token_secret.clear();
617 Ok(())
618}
619
620pub fn tweet(ctx: &mut CommandContext, text: &str, image_path: &Path) -> Result<()> {
621 ensure_state_loaded(ctx);
622 if !ctx.globals.twitter.is_authorized() {
623 bail!("Twitter is not authorized");
624 }
625 let consumer_key = gameexe_value(ctx, "TWITTER.API_KEY");
626 let consumer_secret = gameexe_value(ctx, "TWITTER.API_SECRET");
627 if consumer_key.is_empty() || consumer_secret.is_empty() {
628 bail!("Twitter API_KEY and API_SECRET must be configured");
629 }
630
631 let image = fs::read(image_path)
632 .with_context(|| format!("read tweet image {}", image_path.display()))?;
633 let image_base64 = base64_encode(&image);
634 let boundary = format!("S-i-g-l-u-s--{}{}", oauth_nonce(), oauth_nonce());
635 let body = format!(
636 "--{boundary}\r\nContent-Disposition: form-data; name=\"media_data\"; \r\n\r\n{image_base64}\r\n--{boundary}--\r\n\r\n"
637 );
638 let oauth = oauth_params(&consumer_key, Some(&ctx.globals.twitter.access_token));
639 let response = twitter_request(
640 "/1.1/media/upload.json",
641 TWITTER_UPLOAD_DOMAIN,
642 "POST",
643 &[],
644 &oauth,
645 &[(
646 "Content-Type",
647 format!("multipart/form-data; boundary={boundary}"),
648 )],
649 body.as_bytes(),
650 &consumer_secret,
651 &ctx.globals.twitter.access_token_secret,
652 )?;
653 let response_text = String::from_utf8_lossy(&response);
654 let media_id = json_string_field(&response_text, "media_id_string")
655 .ok_or_else(|| anyhow!("Twitter media upload response omitted media_id_string"))?;
656
657 let oauth = oauth_params(&consumer_key, Some(&ctx.globals.twitter.access_token));
658 let request = vec![
659 ("status".to_string(), text.to_string()),
660 ("media_ids".to_string(), media_id),
661 ];
662 let response = twitter_request(
663 "/1.1/statuses/update.json",
664 TWITTER_API_DOMAIN,
665 "POST",
666 &request,
667 &oauth,
668 &[],
669 &[],
670 &consumer_secret,
671 &ctx.globals.twitter.access_token_secret,
672 )?;
673 let response_text = String::from_utf8_lossy(&response);
674 if let Some(message) = json_error_message(&response_text) {
675 bail!("Twitter posting failed: {message}");
676 }
677 if serde_json::from_str::<serde_json::Value>(&response_text)
678 .ok()
679 .and_then(|value| value.get("errors").cloned())
680 .is_some()
681 {
682 bail!("Twitter posting failed: Twitter returned an unspecified posting error");
683 }
684 Ok(())
685}
686
687fn json_string_field(text: &str, field: &str) -> Option<String> {
688 let value: serde_json::Value = serde_json::from_str(text).ok()?;
689 value.get(field)?.as_str().map(str::to_string)
690}
691
692fn json_error_message(text: &str) -> Option<String> {
693 let value: serde_json::Value = serde_json::from_str(text).ok()?;
694 value
695 .get("errors")?
696 .as_array()?
697 .first()?
698 .get("message")?
699 .as_str()
700 .map(str::to_string)
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 #[test]
708 fn percent_encoding_matches_oauth_rules() {
709 assert_eq!(
710 percent_encode("Ladies + Gentlemen"),
711 "Ladies%20%2B%20Gentlemen"
712 );
713 assert_eq!(percent_encode("โ"), "%E2%98%83");
714 assert_eq!(percent_decode("a%20b%2Bc"), "a b+c");
715 }
716
717 #[test]
718 fn base64_vectors() {
719 assert_eq!(base64_encode(b""), "");
720 assert_eq!(base64_encode(b"f"), "Zg==");
721 assert_eq!(base64_encode(b"fo"), "Zm8=");
722 assert_eq!(base64_encode(b"foo"), "Zm9v");
723 }
724
725 #[test]
726 fn sha1_vector() {
727 let digest = sha1(b"abc");
728 assert_eq!(
729 digest,
730 [
731 0xa9, 0x99, 0x3e, 0x36, 0x47, 0x06, 0x81, 0x6a, 0xba, 0x3e, 0x25, 0x71, 0x78, 0x50,
732 0xc2, 0x6c, 0x9c, 0xd0, 0xd8, 0x9d,
733 ]
734 );
735 }
736
737 #[test]
738 fn hmac_sha1_vector() {
739 assert_eq!(
741 hmac_base64_encode("Jefe", "what do ya want for nothing?"),
742 "7/zfauXrL6LSdBbV8YTfnCWafHk="
743 );
744 }
745
746 #[test]
747 fn json_field_handles_escapes() {
748 assert_eq!(
749 json_string_field(r#"{"name":"Siglus \u30c6\u30b9\u30c8"}"#, "name").as_deref(),
750 Some("Siglus ใในใ")
751 );
752 }
753}