Skip to main content

siglus_scene_vm/runtime/
twitter.rs

1//! Desktop Siglus Twitter integration.
2//!
3//! The original engine implements this in `eng_twitter.cpp` with WinINet,
4//! OAuth 1.0a and the Twitter v1.1 upload/status APIs.  Keep the protocol and
5//! request construction compatible here, but use the project's existing
6//! `ureq` transport and a cross-platform sidecar instead of the Windows
7//! registry.
8
9#![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    // Match tona3 query_str_to_map(): OAuth endpoint/callback values are split
130    // on '&' and '=', but are not URL-decoded at this stage.
131    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    // `eng_twitter.cpp` signs request parameters together with OAuth parameters,
326    // sorts by key only, then places that complete list in the Authorization
327    // header.  This is intentionally preserved, including request params in the
328    // OAuth header and query string for POST requests.
329    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    // WinINet's original twitter_request() returned success once the response
408    // body was read, regardless of the HTTP status.  Leave semantic errors to
409    // the caller's OAuth/JSON parsing for parity.
410    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    // The original embedded browser accepts a navigation only when it begins
518    // with #TWITTER.CALLBACK_URL, irrespective of URL scheme. Preserve that
519    // rule for pasted callbacks (including custom schemes). Desktop ports also
520    // accept a bare verifier because the system browser cannot be intercepted.
521    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        // CIESink::OnBeforeNavigate2 passes response.substr(callback.size()+1),
526        // i.e. it consumes exactly the one separator byte after the callback.
527        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        // Original query["oauth_token"] yields an empty string when omitted; it
566        // does not compare this token with G_oauth_request_token.
567        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        // RFC 2202 test case 2.
740        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}