Skip to main content

systemprompt_cli/commands/core/files/
upload.rs

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