Skip to main content

systemprompt_cli/commands/core/files/
upload.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Result, anyhow};
4use base64::Engine;
5use base64::engine::general_purpose::STANDARD;
6use clap::Args;
7use sha2::{Digest, Sha256};
8use systemprompt_files::{FileUploadRequest, FileUploadService, FilesConfig};
9use systemprompt_identifiers::{ContextId, SessionId, UserId};
10use systemprompt_runtime::AppContext;
11use tokio::fs;
12
13use super::types::FileUploadOutput;
14use crate::CliConfig;
15use crate::shared::CommandResult;
16
17#[derive(Debug, Clone, Args)]
18pub struct UploadArgs {
19    #[arg(help = "Path to file to upload")]
20    pub file_path: PathBuf,
21
22    #[arg(long, help = "Context ID (required)")]
23    pub context: String,
24
25    #[arg(long, help = "User ID")]
26    pub user: Option<String>,
27
28    #[arg(long, help = "Session ID")]
29    pub session: Option<String>,
30
31    #[arg(long, help = "Mark as AI-generated content")]
32    pub ai: bool,
33}
34
35pub async fn execute(
36    args: UploadArgs,
37    _config: &CliConfig,
38) -> Result<CommandResult<FileUploadOutput>> {
39    let ctx = AppContext::new().await?;
40    let files_config = FilesConfig::get()?;
41    let service = FileUploadService::new(ctx.db_pool(), files_config.clone())?;
42
43    if !service.is_enabled() {
44        return Err(anyhow!("File uploads are disabled in configuration"));
45    }
46
47    let file_path = args
48        .file_path
49        .canonicalize()
50        .map_err(|e| anyhow!("File not found: {} - {}", args.file_path.display(), e))?;
51
52    let bytes = fs::read(&file_path).await?;
53    let bytes_base64 = STANDARD.encode(&bytes);
54    let digest = Sha256::digest(&bytes);
55    let checksum_sha256 = digest.iter().fold(String::with_capacity(64), |mut acc, b| {
56        use std::fmt::Write;
57        let _ = write!(acc, "{b:02x}");
58        acc
59    });
60    let size_bytes = bytes.len() as i64;
61
62    let mime_type = detect_mime_type(&file_path);
63    let filename = file_path
64        .file_name()
65        .and_then(|n| n.to_str())
66        .map(String::from);
67
68    let context_id = ContextId::new(args.context);
69
70    let request = FileUploadRequest {
71        name: filename,
72        mime_type: mime_type.clone(),
73        bytes_base64,
74        context_id,
75        user_id: args.user.map(UserId::new),
76        session_id: args.session.map(SessionId::new),
77        trace_id: None,
78    };
79
80    let result = service.upload_file(request).await?;
81
82    let output = FileUploadOutput {
83        file_id: result.file_id,
84        path: result.path,
85        public_url: result.public_url,
86        size_bytes,
87        mime_type,
88        checksum_sha256,
89    };
90
91    Ok(CommandResult::card(output).with_title("File Uploaded"))
92}
93
94const EXTENSION_MIME_TABLE: &[(&[&str], &str)] = &[
95    (&["jpg", "jpeg"], "image/jpeg"),
96    (&["png"], "image/png"),
97    (&["gif"], "image/gif"),
98    (&["webp"], "image/webp"),
99    (&["svg"], "image/svg+xml"),
100    (&["bmp"], "image/bmp"),
101    (&["tiff", "tif"], "image/tiff"),
102    (&["ico"], "image/x-icon"),
103    (&["pdf"], "application/pdf"),
104    (&["doc"], "application/msword"),
105    (
106        &["docx"],
107        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
108    ),
109    (&["xls"], "application/vnd.ms-excel"),
110    (
111        &["xlsx"],
112        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
113    ),
114    (&["ppt"], "application/vnd.ms-powerpoint"),
115    (
116        &["pptx"],
117        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
118    ),
119    (&["txt"], "text/plain"),
120    (&["csv"], "text/csv"),
121    (&["md"], "text/markdown"),
122    (&["html", "htm"], "text/html"),
123    (&["json"], "application/json"),
124    (&["xml"], "application/xml"),
125    (&["rtf"], "application/rtf"),
126    (&["mp3"], "audio/mpeg"),
127    (&["wav"], "audio/wav"),
128    (&["ogg"], "audio/ogg"),
129    (&["aac"], "audio/aac"),
130    (&["flac"], "audio/flac"),
131    (&["m4a"], "audio/mp4"),
132    (&["mp4"], "video/mp4"),
133    (&["webm"], "video/webm"),
134    (&["mov"], "video/quicktime"),
135    (&["avi"], "video/x-msvideo"),
136    (&["mkv"], "video/x-matroska"),
137];
138
139pub fn detect_mime_type(path: &Path) -> String {
140    let extension = path
141        .extension()
142        .and_then(|e| e.to_str())
143        .map(str::to_lowercase);
144    let Some(ext) = extension.as_deref() else {
145        return "application/octet-stream".to_string();
146    };
147    EXTENSION_MIME_TABLE
148        .iter()
149        .find(|(exts, _)| exts.contains(&ext))
150        .map_or("application/octet-stream", |(_, mime)| *mime)
151        .to_string()
152}