1use std::path::{Path, PathBuf};
8
9use clap::Subcommand;
10
11use crate::cli::write::{Gate, Intent, check};
12use crate::cli::{Session, emit, report};
13use crate::exit::ExitCode;
14use crate::render::{Format, entity as render, image, machine};
15
16#[derive(Debug, Subcommand)]
17pub enum AttachmentCommand {
18 #[command(long_about = crate::cli::help::md(crate::cli::help::ATTACHMENT_LIST))]
20 List { key: String },
21 #[command(long_about = crate::cli::help::md(crate::cli::help::ATTACHMENT_DOWNLOAD))]
23 Download {
24 key: String,
25 attachment: String,
26 #[arg(long, short = 'o')]
28 out: PathBuf,
29 #[arg(long)]
31 force: bool,
32 },
33 #[command(long_about = crate::cli::help::md(crate::cli::help::ATTACHMENT_SHOW))]
35 Show { key: String, attachment: String },
36 #[command(long_about = crate::cli::help::md(crate::cli::help::ATTACHMENT_UPLOAD))]
38 Upload { key: String, file: PathBuf },
39 #[command(long_about = crate::cli::help::md(crate::cli::help::ATTACHMENT_DELETE))]
41 Delete {
42 key: String,
43 attachment: String,
45 },
46}
47
48pub async fn run(command: &AttachmentCommand, session: &Session) -> ExitCode {
49 match command {
50 AttachmentCommand::List { key } => list(key, session).await,
51 AttachmentCommand::Download {
52 key,
53 attachment,
54 out,
55 force,
56 } => download(key, attachment, out, *force, session).await,
57 AttachmentCommand::Show { key, attachment } => show(key, attachment, session).await,
58 AttachmentCommand::Upload { key, file } => upload(key, file, session).await,
59 AttachmentCommand::Delete { key, attachment } => delete(key, attachment, session).await,
60 }
61}
62
63async fn list(target: &str, session: &Session) -> ExitCode {
64 let (client, key) = match session.client_for(target).await {
65 Ok(pair) => pair,
66 Err(code) => return code,
67 };
68 let key = key.as_str();
69
70 match client.attachments(key).await {
71 Ok(attachments) => {
72 let rendered = match session.render.format {
73 Format::Text => Ok(render::attachments(key, &attachments, &session.render)),
74 Format::JsonRaw => machine(&attachments, Format::Json),
75 other => machine(&attachments, other),
76 };
77 match rendered {
78 Ok(text) => {
79 emit(&text);
80 ExitCode::Success
81 }
82 Err(error) => report(&error, ExitCode::Failure),
83 }
84 }
85 Err(error) => {
86 let code = error.exit_code();
87 report(&error, code)
88 }
89 }
90}
91
92fn safe_filename(raw: &str, fallback: &str) -> String {
98 let base = raw
99 .rsplit(['/', '\\'])
100 .next()
101 .unwrap_or(raw)
102 .trim()
103 .trim_matches('.');
104
105 let cleaned: String = base
106 .chars()
107 .filter(|c| !c.is_control() && !matches!(c, ':' | '*' | '?' | '"' | '<' | '>' | '|'))
108 .collect();
109
110 if cleaned.is_empty() {
111 fallback.to_owned()
112 } else {
113 cleaned
114 }
115}
116
117async fn download(
118 target: &str,
119 attachment: &str,
120 out: &Path,
121 force: bool,
122 session: &Session,
123) -> ExitCode {
124 let (client, key) = match session.client_for(target).await {
125 Ok(pair) => pair,
126 Err(code) => return code,
127 };
128 let key = key.as_str();
129
130 let attachments = match client.attachments(key).await {
131 Ok(attachments) => attachments,
132 Err(error) => {
133 let code = error.exit_code();
134 return report(&error, code);
135 }
136 };
137
138 let Some(found) = attachments
139 .iter()
140 .find(|candidate| candidate.id == attachment || candidate.name == attachment)
141 else {
142 return report(
143 &format!("issue {key} has no attachment `{attachment}`"),
144 ExitCode::NotFound,
145 );
146 };
147
148 let Some(url) = found.content.as_deref() else {
149 return report(
150 &format!("attachment `{attachment}` has no download URL"),
151 ExitCode::ApiRejected,
152 );
153 };
154
155 let destination = out.join(safe_filename(&found.name, &found.id));
156 if destination.exists() && !force {
157 return report(
158 &format!(
159 "{} already exists; pass --force to overwrite",
160 destination.display()
161 ),
162 ExitCode::ConfirmationRequired,
163 );
164 }
165
166 let bytes = match client.download(url).await {
167 Ok(bytes) => bytes,
168 Err(error) => {
169 let code = error.exit_code();
170 return report(&error, code);
171 }
172 };
173
174 if let Err(error) = std::fs::create_dir_all(out) {
175 return report(&error, ExitCode::Failure);
176 }
177 if let Err(error) = std::fs::write(&destination, &bytes) {
178 return report(&error, ExitCode::Failure);
179 }
180
181 emit(&format!("{}\n", destination.display()));
182 ExitCode::Success
183}
184
185async fn show(target: &str, attachment: &str, session: &Session) -> ExitCode {
192 let (client, key) = match session.client_for(target).await {
193 Ok(pair) => pair,
194 Err(code) => return code,
195 };
196 let key = key.as_str();
197
198 let found = match find_attachment(&client, key, attachment).await {
199 Ok(found) => found,
200 Err(code) => return code,
201 };
202
203 if session.render.format != Format::Text {
207 return match machine(&found, session.render.format) {
208 Ok(text) => {
209 emit(&text);
210 ExitCode::Success
211 }
212 Err(error) => report(&error, ExitCode::Failure),
213 };
214 }
215
216 let hint = format!(" ytcli attachment download {key} {} -o .", found.id);
217 let what = describe(&found);
218
219 let Some(protocol) = image::protocol() else {
220 emit(&format!(
221 "{what} — this terminal cannot draw images:\n{hint}\n"
222 ));
223 return ExitCode::Success;
224 };
225
226 let Some(url) = found.content.as_deref() else {
227 return report(
228 &format!("attachment `{attachment}` has no download URL"),
229 ExitCode::ApiRejected,
230 );
231 };
232
233 let bytes = match client.download(url).await {
234 Ok(bytes) => bytes,
235 Err(error) => {
236 let code = error.exit_code();
237 return report(&error, code);
238 }
239 };
240
241 let Some(kind) = image::Kind::of(&bytes) else {
242 emit(&format!("{what} is not an image:\n{hint}\n"));
243 return ExitCode::Success;
244 };
245
246 if !protocol.carries(kind) {
247 emit(&format!(
248 "{what} is {}, which this terminal cannot draw inline:\n{hint}\n",
249 kind.name()
250 ));
251 return ExitCode::Success;
252 }
253
254 emit(&image::draw(
255 protocol,
256 &bytes,
257 &found.name,
258 session.render.width,
259 ));
260 emit(&format!("{what}\n"));
264 ExitCode::Success
265}
266
267fn describe(found: &crate::api::models::Attachment) -> String {
269 match found.size {
270 Some(size) => format!("{} ({})", found.name, render::human_size(size)),
271 None => found.name.clone(),
272 }
273}
274
275async fn find_attachment(
277 client: &crate::api::Client,
278 key: &str,
279 attachment: &str,
280) -> Result<crate::api::models::Attachment, ExitCode> {
281 let attachments = match client.attachments(key).await {
282 Ok(attachments) => attachments,
283 Err(error) => {
284 let code = error.exit_code();
285 return Err(report(&error, code));
286 }
287 };
288
289 attachments
290 .into_iter()
291 .find(|candidate| candidate.id == attachment || candidate.name == attachment)
292 .ok_or_else(|| {
293 report(
294 &format!("issue {key} has no attachment `{attachment}`"),
295 ExitCode::NotFound,
296 )
297 })
298}
299
300async fn upload(target: &str, file: &Path, session: &Session) -> ExitCode {
301 let (client, key) = match session.client_for(target).await {
302 Ok(pair) => pair,
303 Err(code) => return code,
304 };
305 let key = key.as_str();
306
307 let bytes = match std::fs::read(file) {
308 Ok(bytes) => bytes,
309 Err(error) => return report(&error, ExitCode::Failure),
310 };
311 let name = file.file_name().map_or_else(
312 || "upload".to_owned(),
313 |name| name.to_string_lossy().into_owned(),
314 );
315
316 let body = serde_json::json!({
317 "file": name,
318 "bytes": bytes.len(),
319 });
320 let targets = [key.to_owned()];
321 let intent = Intent {
322 action: &format!("upload {name} to {key}"),
323 targets: &targets,
324 body: &body,
325 always_confirm: false,
326 };
327 if let Gate::Stop(code) = check(&intent, session) {
328 return code;
329 }
330
331 match client.upload(key, &name, bytes).await {
332 Ok(attachment) => {
333 emit(&format!("{key} attachment {}\n", attachment.id));
334 ExitCode::Success
335 }
336 Err(error) => {
337 let code = error.exit_code();
338 report(&error, code)
339 }
340 }
341}
342
343async fn delete(target: &str, attachment: &str, session: &Session) -> ExitCode {
349 let (client, key) = match session.client_for(target).await {
350 Ok(pair) => pair,
351 Err(code) => return code,
352 };
353 let key = key.as_str();
354
355 let attachments = match client.attachments(key).await {
356 Ok(attachments) => attachments,
357 Err(error) => {
358 let code = error.exit_code();
359 return report(&error, code);
360 }
361 };
362
363 let Some(found) = attachments
364 .iter()
365 .find(|candidate| candidate.id == attachment || candidate.name == attachment)
366 else {
367 return report(
368 &format!("issue {key} has no attachment `{attachment}`"),
369 ExitCode::NotFound,
370 );
371 };
372
373 let body = serde_json::json!({ "file": found.name, "id": found.id });
374 let targets = [key.to_owned()];
375 let intent = Intent {
376 action: &format!("delete {} from {key}", found.name),
377 targets: &targets,
378 body: &body,
379 always_confirm: true,
382 };
383 if let Gate::Stop(code) = check(&intent, session) {
384 return code;
385 }
386
387 match client.delete_attachment(key, &found.id).await {
388 Ok(()) => {
389 emit(&format!("{key} deleted attachment {}\n", found.name));
390 ExitCode::Success
391 }
392 Err(error) => {
393 let code = error.exit_code();
394 report(&error, code)
395 }
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
406 fn a_traversing_name_is_reduced_to_its_last_segment() {
407 assert_eq!(safe_filename("../../etc/passwd", "id"), "passwd");
408 assert_eq!(safe_filename("/tmp/evil.sh", "id"), "evil.sh");
409 assert_eq!(safe_filename("a\\b\\c.txt", "id"), "c.txt");
410 }
411
412 #[test]
413 fn a_name_that_is_only_dots_falls_back_to_the_id() {
414 assert_eq!(safe_filename("..", "42"), "42");
415 assert_eq!(safe_filename(" ", "42"), "42");
416 }
417
418 #[test]
419 fn an_ordinary_name_is_left_alone() {
420 assert_eq!(safe_filename("report.pdf", "id"), "report.pdf");
421 }
422}