use std::path::Path;
use anyhow::{Context, Result};
use grammers_client::Client;
use grammers_client::media::Media;
use grammers_client::message::Message;
use grammers_session::types::PeerRef;
use tokio::io::AsyncWriteExt;
use super::login::connect;
use crate::config::types::Config;
const SCAN_LIMIT: usize = 500;
const PROGRESS_STEP: u64 = 8 * 1024 * 1024;
fn safe_file_name(name: &str) -> String {
let base = Path::new(name)
.file_name()
.and_then(|part| part.to_str())
.map(str::trim)
.filter(|part| !part.is_empty());
match base {
Some(base) => base.to_string(),
None => "unnamed.bin".to_string(),
}
}
async fn stream_to_file(client: &Client, media: &Media, path: &Path, size: u64) -> Result<u64> {
let mut file = tokio::fs::File::create(path)
.await
.with_context(|| format!("creating {}", path.display()))?;
let mut stream = client.iter_download(media);
let mut got: u64 = 0;
let mut next_progress = PROGRESS_STEP;
while let Some(chunk) = stream.next().await? {
file.write_all(&chunk)
.await
.with_context(|| format!("writing {}", path.display()))?;
got += chunk.len() as u64;
if size >= PROGRESS_STEP && got >= next_progress {
eprintln!(" {got}/{size} bytes");
next_progress = (got / PROGRESS_STEP + 1) * PROGRESS_STEP;
}
}
file.flush()
.await
.with_context(|| format!("flushing {}", path.display()))?;
Ok(got)
}
async fn resolve_peer(client: &Client, who: &str) -> Result<PeerRef> {
if let Ok(id) = who.parse::<i64>() {
let mut dialogs = client.iter_dialogs();
while let Some(dialog) = dialogs.next().await? {
let peer_id = dialog.peer_id();
if peer_id.bot_api_dialog_id() == Some(id) || peer_id.bare_id() == Some(id) {
return Ok(dialog.peer_ref());
}
}
anyhow::bail!("peer {id} not found in the userbot's dialogs");
}
let username = who.strip_prefix('@').unwrap_or(who);
let peer = client
.resolve_username(username)
.await
.with_context(|| format!("resolving {who}"))?
.ok_or_else(|| anyhow::anyhow!("cannot resolve peer: {who}"))?;
peer.to_ref()
.await
.map_err(|error| anyhow::anyhow!("peer ref for {who} failed: {error}"))?
.ok_or_else(|| anyhow::anyhow!("peer {who} has no usable access hash yet"))
}
fn document_info(message: &Message) -> Option<(String, u64)> {
if let Some(Media::Document(doc)) = message.media() {
let name = doc.name().unwrap_or("unnamed.bin").to_string();
Some((name, doc.size().unwrap_or(0) as u64))
} else {
None
}
}
async fn list_documents(client: &Client, peer: PeerRef, limit: usize) -> Result<()> {
let mut iter = client.iter_messages(peer).limit(SCAN_LIMIT);
let mut shown = 0usize;
while let Some(message) = iter.next().await? {
if shown >= limit {
break;
}
if let Some((name, size)) = document_info(&message) {
let text_head: String = message.text().chars().take(80).collect();
println!(
"msg_id={} size={} name={:?} text={:?}",
message.id(),
size,
name,
text_head
);
shown += 1;
}
}
if shown == 0 {
println!("(no documents in scanned window)");
}
Ok(())
}
async fn find_document(client: &Client, peer: PeerRef, message: &str) -> Result<Message> {
if message == "latest" {
let mut iter = client.iter_messages(peer).limit(SCAN_LIMIT);
while let Some(m) = iter.next().await? {
if document_info(&m).is_some() {
return Ok(m);
}
}
anyhow::bail!("no document found in the last {SCAN_LIMIT} messages");
}
let wanted: i32 = message
.parse()
.with_context(|| format!("bad message id: {message}"))?;
let found = client.get_messages_by_id(peer, &[wanted]).await?;
found
.into_iter()
.next()
.flatten()
.ok_or_else(|| anyhow::anyhow!("message {wanted} not found"))
}
pub(crate) async fn cmd_userbot_download(
config: &Config,
peer: &str,
message: &str,
outdir: Option<&str>,
list: bool,
) -> Result<()> {
let cfg = &config.channels.telegram.userbot;
let (client, _session, updates, runner) = connect(cfg).await?;
if !client.is_authorized().await? {
anyhow::bail!("userbot is not authorized; run `opencrabs channel userbot-login`");
}
let result = run(&client, peer, message, outdir, list).await;
drop(updates);
drop(runner);
result
}
async fn run(
client: &Client,
peer: &str,
message: &str,
outdir: Option<&str>,
list: bool,
) -> Result<()> {
let peer_ref = resolve_peer(client, peer).await?;
if list {
return list_documents(client, peer_ref, 30).await;
}
let dir = Path::new(outdir.unwrap_or("."));
tokio::fs::create_dir_all(dir)
.await
.with_context(|| format!("creating output directory {}", dir.display()))?;
let message = find_document(client, peer_ref, message).await?;
let media = message
.media()
.ok_or_else(|| anyhow::anyhow!("message {} has no media", message.id()))?;
let (uploader_name, size) = document_info(&message)
.ok_or_else(|| anyhow::anyhow!("message {} media is not a document", message.id()))?;
let name = safe_file_name(&uploader_name);
if name != uploader_name {
eprintln!("note: uploader name {uploader_name:?} reduced to {name:?}");
}
let path = dir.join(&name);
if path.parent() != Some(dir) {
anyhow::bail!("refusing to write outside {}", dir.display());
}
let part = path.with_file_name(format!("{name}.part"));
eprintln!(
"downloading msg {} -> {} ({} bytes)",
message.id(),
path.display(),
size
);
let got = match stream_to_file(client, &media, &part, size).await {
Ok(got) => got,
Err(error) => {
let _ = tokio::fs::remove_file(&part).await;
return Err(error);
}
};
tokio::fs::rename(&part, &path)
.await
.with_context(|| format!("renaming {} to {}", part.display(), path.display()))?;
println!("OK {} {got} bytes", path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_file_name_keeps_a_plain_name() {
assert_eq!(safe_file_name("report.pdf"), "report.pdf");
}
#[test]
fn safe_file_name_reduces_paths_to_their_last_component() {
assert_eq!(safe_file_name("a/b/report.pdf"), "report.pdf");
assert_eq!(
safe_file_name("../../.ssh/authorized_keys"),
"authorized_keys"
);
assert_eq!(safe_file_name("/etc/passwd"), "passwd");
}
#[test]
fn safe_file_name_replaces_names_with_nothing_usable() {
assert_eq!(safe_file_name(""), "unnamed.bin");
assert_eq!(safe_file_name(".."), "unnamed.bin");
assert_eq!(safe_file_name("."), "unnamed.bin");
assert_eq!(safe_file_name("a/.."), "unnamed.bin");
assert_eq!(safe_file_name(" "), "unnamed.bin");
}
#[test]
fn joined_path_stays_inside_the_output_directory() {
let dir = std::env::temp_dir().join("outdir");
for uploader_name in [
"report.pdf",
"../../.ssh/authorized_keys",
"/etc/passwd",
"..",
"",
] {
let joined = dir.join(safe_file_name(uploader_name));
assert_eq!(
joined.parent(),
Some(dir.as_path()),
"uploader name {uploader_name:?} escaped the output directory"
);
}
}
}