| 64 |
64 |
|
pub has_local_blob: bool,
|
| 65 |
65 |
|
}
|
| 66 |
66 |
|
|
| 67 |
|
- |
// Commands
|
| 68 |
|
- |
|
| 69 |
|
- |
/// Attach a file to a task or project.
|
|
67 |
+ |
/// Why attaching or spooling a file stopped.
|
| 70 |
68 |
|
///
|
| 71 |
|
- |
/// Reads the file, computes SHA-256, copies to blob store (dedup), and creates
|
| 72 |
|
- |
/// the attachment record. The `file_path` comes from the JS file picker dialog.
|
| 73 |
|
- |
#[tauri::command]
|
| 74 |
|
- |
#[instrument(skip_all)]
|
| 75 |
|
- |
pub async fn add_attachment(
|
| 76 |
|
- |
state: State<'_, Arc<AppState>>,
|
|
69 |
+ |
/// Two arms rather than one string, because the two callers say a refusal
|
|
70 |
+ |
/// differently and neither can tell which it was from the text. A command
|
|
71 |
+ |
/// answers with `ApiError`; the described attachments column puts a refusal back
|
|
72 |
+ |
/// on the field the file came from and lets a failure be a 500. The split is
|
|
73 |
+ |
/// whose fault it is: `Refused` is the user's and is fixable by picking another
|
|
74 |
+ |
/// file, `Failed` is ours.
|
|
75 |
+ |
#[derive(Debug)]
|
|
76 |
+ |
pub(crate) enum AttachFailure {
|
|
77 |
+ |
/// The file will not do, and saying so is the whole answer.
|
|
78 |
+ |
Refused(String),
|
|
79 |
+ |
/// What it was to be attached to is not there.
|
|
80 |
+ |
///
|
|
81 |
+ |
/// Its own arm rather than a [`Refused`](Self::Refused) with a sentence in
|
|
82 |
+ |
/// it, because the command answered a bad parent id with a 404 before this
|
|
83 |
+ |
/// was lifted out and still has to. A caller that has no route to a
|
|
84 |
+ |
/// not-found — the described column, whose project came from the address it
|
|
85 |
+ |
/// was already rendering — reads it as a refusal.
|
|
86 |
+ |
Missing {
|
|
87 |
+ |
/// What was looked for.
|
|
88 |
+ |
resource: &'static str,
|
|
89 |
+ |
/// Which one.
|
|
90 |
+ |
id: String,
|
|
91 |
+ |
},
|
|
92 |
+ |
/// Something on our side did not work.
|
|
93 |
+ |
Failed(String),
|
|
94 |
+ |
}
|
|
95 |
+ |
|
|
96 |
+ |
impl From<AttachFailure> for ApiError {
|
|
97 |
+ |
fn from(failure: AttachFailure) -> Self {
|
|
98 |
+ |
match failure {
|
|
99 |
+ |
AttachFailure::Refused(message) => Self::validation("filePath", message),
|
|
100 |
+ |
AttachFailure::Missing { resource, id } => Self::not_found(resource, id),
|
|
101 |
+ |
AttachFailure::Failed(message) => Self::internal(message),
|
|
102 |
+ |
}
|
|
103 |
+ |
}
|
|
104 |
+ |
}
|
|
105 |
+ |
|
|
106 |
+ |
impl AttachFailure {
|
|
107 |
+ |
/// The sentence to put in front of the user, whichever arm it is.
|
|
108 |
+ |
pub(crate) fn message(&self) -> String {
|
|
109 |
+ |
match self {
|
|
110 |
+ |
Self::Refused(message) | Self::Failed(message) => message.clone(),
|
|
111 |
+ |
Self::Missing { resource, id } => format!("{resource} not found: {id}"),
|
|
112 |
+ |
}
|
|
113 |
+ |
}
|
|
114 |
+ |
|
|
115 |
+ |
/// The same failure said as a bad request rather than a complaint about a
|
|
116 |
+ |
/// field. What the two open paths answer with: they are addressed at an
|
|
117 |
+ |
/// attachment that exists, not at a file the user chose, so there is no
|
|
118 |
+ |
/// field for the message to hang on.
|
|
119 |
+ |
pub(crate) fn bad_request(self) -> ApiError {
|
|
120 |
+ |
match self {
|
|
121 |
+ |
Self::Failed(message) => ApiError::internal(message),
|
|
122 |
+ |
other => ApiError::bad_request(other.message()),
|
|
123 |
+ |
}
|
|
124 |
+ |
}
|
|
125 |
+ |
}
|
|
126 |
+ |
|
|
127 |
+ |
/// Attach a file that is already on disk, synchronously.
|
|
128 |
+ |
///
|
|
129 |
+ |
/// The body of [`add_attachment`], lifted out of the command 2026-08-09 so the
|
|
130 |
+ |
/// described project dashboard's `POST /projects/{id}/attachments` can call the
|
|
131 |
+ |
/// same code rather than a second copy of it. A `quasi_router` handler is a
|
|
132 |
+ |
/// plain `fn(&S, Params)` with nothing async about it, so this is sync and the
|
|
133 |
+ |
/// command is what puts it on the blocking pool.
|
|
134 |
+ |
///
|
|
135 |
+ |
/// Reads the file, computes SHA-256, copies to the blob store (dedup), and
|
|
136 |
+ |
/// creates the attachment record.
|
|
137 |
+ |
pub(crate) fn attach_path(
|
|
138 |
+ |
state: &AppState,
|
| 77 |
139 |
|
task_id: Option<TaskId>,
|
| 78 |
140 |
|
project_id: Option<ProjectId>,
|
| 79 |
|
- |
file_path: String,
|
| 80 |
|
- |
) -> Result<AttachmentResponse, ApiError> {
|
|
141 |
+ |
file_path: &str,
|
|
142 |
+ |
) -> Result<goingson_core::Attachment, AttachFailure> {
|
| 81 |
143 |
|
// Validate at least one parent
|
| 82 |
144 |
|
if task_id.is_none() && project_id.is_none() {
|
| 83 |
|
- |
return Err(ApiError::validation_msg(
|
| 84 |
|
- |
"Either taskId or projectId is required",
|
|
145 |
+ |
return Err(AttachFailure::Refused(
|
|
146 |
+ |
"Either a task or a project is required".to_owned(),
|
| 85 |
147 |
|
));
|
| 86 |
148 |
|
}
|
| 87 |
149 |
|
|
| 88 |
150 |
|
// Verify the parent exists *before* touching the blob store, so a bad id
|
| 89 |
151 |
|
// fails fast instead of after a disk write orphans a blob.
|
| 90 |
|
- |
if let Some(tid) = task_id {
|
| 91 |
|
- |
state
|
|
152 |
+ |
if let Some(tid) = task_id
|
|
153 |
+ |
&& state
|
| 92 |
154 |
|
.tasks
|
| 93 |
|
- |
.get_by_id(tid, DESKTOP_USER_ID)?
|
| 94 |
|
- |
.or_not_found("task", tid)?;
|
|
155 |
+ |
.get_by_id(tid, DESKTOP_USER_ID)
|
|
156 |
+ |
.map_err(|error| AttachFailure::Failed(error.to_string()))?
|
|
157 |
+ |
.is_none()
|
|
158 |
+ |
{
|
|
159 |
+ |
return Err(AttachFailure::Missing {
|
|
160 |
+ |
resource: "task",
|
|
161 |
+ |
id: tid.to_string(),
|
|
162 |
+ |
});
|
| 95 |
163 |
|
}
|
| 96 |
|
- |
if let Some(pid) = project_id {
|
| 97 |
|
- |
state
|
|
164 |
+ |
if let Some(pid) = project_id
|
|
165 |
+ |
&& state
|
| 98 |
166 |
|
.projects
|
| 99 |
|
- |
.get_by_id(pid, DESKTOP_USER_ID)?
|
| 100 |
|
- |
.or_not_found("project", pid)?;
|
|
167 |
+ |
.get_by_id(pid, DESKTOP_USER_ID)
|
|
168 |
+ |
.map_err(|error| AttachFailure::Failed(error.to_string()))?
|
|
169 |
+ |
.is_none()
|
|
170 |
+ |
{
|
|
171 |
+ |
return Err(AttachFailure::Missing {
|
|
172 |
+ |
resource: "project",
|
|
173 |
+ |
id: pid.to_string(),
|
|
174 |
+ |
});
|
| 101 |
175 |
|
}
|
| 102 |
176 |
|
|
| 103 |
|
- |
let source_path = Path::new(&file_path);
|
|
177 |
+ |
let source_path = Path::new(file_path);
|
| 104 |
178 |
|
|
| 105 |
179 |
|
// Validate path exists and is a file
|
| 106 |
180 |
|
if !source_path.is_file() {
|
| 107 |
|
- |
return Err(ApiError::validation(
|
| 108 |
|
- |
"filePath",
|
| 109 |
|
- |
"File does not exist or is not a regular file",
|
|
181 |
+ |
return Err(AttachFailure::Refused(
|
|
182 |
+ |
"File does not exist or is not a regular file".to_owned(),
|
| 110 |
183 |
|
));
|
| 111 |
184 |
|
}
|
| 112 |
185 |
|
|
| 113 |
186 |
|
// Validate no path traversal
|
| 114 |
187 |
|
if file_path.contains("..") {
|
| 115 |
|
- |
return Err(ApiError::validation(
|
| 116 |
|
- |
"filePath",
|
| 117 |
|
- |
"Path traversal not allowed",
|
|
188 |
+ |
return Err(AttachFailure::Refused(
|
|
189 |
+ |
"Path traversal not allowed".to_owned(),
|
| 118 |
190 |
|
));
|
| 119 |
191 |
|
}
|
| 120 |
192 |
|
|
| 121 |
193 |
|
// Read file metadata
|
| 122 |
194 |
|
let metadata = std::fs::metadata(source_path)
|
| 123 |
|
- |
.map_api_err("Failed to read file metadata", ApiError::internal)?;
|
|
195 |
+ |
.map_err(|error| AttachFailure::Failed(format!("Failed to read file metadata: {error}")))?;
|
| 124 |
196 |
|
|
| 125 |
197 |
|
if metadata.len() > MAX_FILE_SIZE {
|
| 126 |
|
- |
return Err(ApiError::validation(
|
| 127 |
|
- |
"filePath",
|
| 128 |
|
- |
format!(
|
| 129 |
|
- |
"File too large ({}, max {})",
|
| 130 |
|
- |
format_file_size(metadata.len() as i64),
|
| 131 |
|
- |
format_file_size(MAX_FILE_SIZE as i64),
|
| 132 |
|
- |
),
|
| 133 |
|
- |
));
|
|
198 |
+ |
return Err(AttachFailure::Refused(format!(
|
|
199 |
+ |
"File too large ({}, max {})",
|
|
200 |
+ |
format_file_size(metadata.len() as i64),
|
|
201 |
+ |
format_file_size(MAX_FILE_SIZE as i64),
|
|
202 |
+ |
)));
|
| 134 |
203 |
|
}
|
| 135 |
204 |
|
|
| 136 |
|
- |
// Read (up to MAX_FILE_SIZE), hash, and write the blob on the blocking pool so a
|
| 137 |
|
- |
// large attachment doesn't stall the async reactor (ultra-fuzz Run #27 Perf S1).
|
| 138 |
|
- |
let source_owned = source_path.to_path_buf();
|
|
205 |
+ |
// Bound the read itself. The `fs::metadata` check above is a separate
|
|
206 |
+ |
// syscall, so a file that grows in between (or a symlink swapped for a
|
|
207 |
+ |
// larger target — `is_file()` follows symlinks) would otherwise be read
|
|
208 |
+ |
// whole into memory, past the limit that check exists to enforce. Taking
|
|
209 |
+ |
// one byte more than the limit is what distinguishes "at the limit" from
|
|
210 |
+ |
// "over it".
|
|
211 |
+ |
use std::io::Read as _;
|
|
212 |
+ |
let handle = std::fs::File::open(source_path)
|
|
213 |
+ |
.map_err(|error| AttachFailure::Failed(format!("Failed to read file: {error}")))?;
|
|
214 |
+ |
let mut file_data = Vec::new();
|
|
215 |
+ |
handle
|
|
216 |
+ |
.take(MAX_FILE_SIZE + 1)
|
|
217 |
+ |
.read_to_end(&mut file_data)
|
|
218 |
+ |
.map_err(|error| AttachFailure::Failed(format!("Failed to read file: {error}")))?;
|
|
219 |
+ |
if file_data.len() as u64 > MAX_FILE_SIZE {
|
|
220 |
+ |
return Err(AttachFailure::Refused(format!(
|
|
221 |
+ |
"File too large (max {})",
|
|
222 |
+ |
format_file_size(MAX_FILE_SIZE as i64)
|
|
223 |
+ |
)));
|
|
224 |
+ |
}
|
|
225 |
+ |
let file_size = file_data.len() as i64;
|
|
226 |
+ |
let hash = {
|
|
227 |
+ |
let mut hasher = Sha256::new();
|
|
228 |
+ |
hasher.update(&file_data);
|
|
229 |
+ |
hex::encode(hasher.finalize())
|
|
230 |
+ |
};
|
|
231 |
+ |
|
| 139 |
232 |
|
let blobs_dir = state.data_dir.join("blobs");
|
| 140 |
|
- |
let (hash, file_size, wrote_new_blob, blob_path) = tokio::task::spawn_blocking(
|
| 141 |
|
- |
move || -> Result<(String, i64, bool, std::path::PathBuf), String> {
|
| 142 |
|
- |
// Bound the read itself. The `fs::metadata` check above is a separate
|
| 143 |
|
- |
// syscall, so a file that grows in between (or a symlink swapped for a
|
| 144 |
|
- |
// larger target — `is_file()` follows symlinks) would otherwise be read
|
| 145 |
|
- |
// whole into memory, past the limit that check exists to enforce. Taking
|
| 146 |
|
- |
// one byte more than the limit is what distinguishes "at the limit" from
|
| 147 |
|
- |
// "over it".
|
| 148 |
|
- |
use std::io::Read as _;
|
| 149 |
|
- |
let handle = std::fs::File::open(&source_owned)
|
| 150 |
|
- |
.map_err(|e| format!("Failed to read file: {e}"))?;
|
| 151 |
|
- |
let mut file_data = Vec::new();
|
| 152 |
|
- |
handle
|
| 153 |
|
- |
.take(MAX_FILE_SIZE + 1)
|
| 154 |
|
- |
.read_to_end(&mut file_data)
|
| 155 |
|
- |
.map_err(|e| format!("Failed to read file: {e}"))?;
|
| 156 |
|
- |
if file_data.len() as u64 > MAX_FILE_SIZE {
|
| 157 |
|
- |
return Err(format!(
|
| 158 |
|
- |
"File too large (max {})",
|
| 159 |
|
- |
format_file_size(MAX_FILE_SIZE as i64)
|
| 160 |
|
- |
));
|
| 161 |
|
- |
}
|
| 162 |
|
- |
let file_size = file_data.len() as i64;
|
| 163 |
|
- |
let hash = {
|
| 164 |
|
- |
let mut hasher = Sha256::new();
|
| 165 |
|
- |
hasher.update(&file_data);
|
| 166 |
|
- |
hex::encode(hasher.finalize())
|
| 167 |
|
- |
};
|
| 168 |
|
- |
std::fs::create_dir_all(&blobs_dir)
|
| 169 |
|
- |
.map_err(|e| format!("Failed to create blobs directory: {e}"))?;
|
| 170 |
|
- |
// Copy to blob store (skip if hash already exists, dedup). Track whether
|
| 171 |
|
- |
// we wrote it so a failed insert can roll back exactly the blob we added.
|
| 172 |
|
- |
let blob_path = blobs_dir.join(&hash);
|
| 173 |
|
- |
let wrote_new_blob = !blob_path.exists();
|
| 174 |
|
- |
if wrote_new_blob {
|
| 175 |
|
- |
std::fs::write(&blob_path, &file_data)
|
| 176 |
|
- |
.map_err(|e| format!("Failed to write blob: {e}"))?;
|
| 177 |
|
- |
}
|
| 178 |
|
- |
Ok((hash, file_size, wrote_new_blob, blob_path))
|
| 179 |
|
- |
},
|
| 180 |
|
- |
)
|
| 181 |
|
- |
.await
|
| 182 |
|
- |
.map_err(|e| ApiError::internal(format!("Attachment task panicked: {e}")))?
|
| 183 |
|
- |
.map_err(ApiError::internal)?;
|
|
233 |
+ |
std::fs::create_dir_all(&blobs_dir).map_err(|error| {
|
|
234 |
+ |
AttachFailure::Failed(format!("Failed to create blobs directory: {error}"))
|
|
235 |
+ |
})?;
|
|
236 |
+ |
// Copy to blob store (skip if hash already exists, dedup). Track whether
|
|
237 |
+ |
// we wrote it so a failed insert can roll back exactly the blob we added.
|
|
238 |
+ |
let blob_path = blobs_dir.join(&hash);
|
|
239 |
+ |
let wrote_new_blob = !blob_path.exists();
|
|
240 |
+ |
if wrote_new_blob {
|
|
241 |
+ |
std::fs::write(&blob_path, &file_data)
|
|
242 |
+ |
.map_err(|error| AttachFailure::Failed(format!("Failed to write blob: {error}")))?;
|
|
243 |
+ |
}
|
| 184 |
244 |
|
|
| 185 |
245 |
|
// Extract filename from path
|
| 186 |
246 |
|
let filename = source_path
|
| 191 |
251 |
|
|
| 192 |
252 |
|
let mime_type = mime_from_extension(&filename).to_string();
|
| 193 |
253 |
|
|
| 194 |
|
- |
let create_result = state.attachments.create(
|
| 195 |
|
- |
DESKTOP_USER_ID,
|
| 196 |
|
- |
NewAttachment {
|
| 197 |
|
- |
task_id,
|
| 198 |
|
- |
project_id,
|
| 199 |
|
- |
filename,
|
| 200 |
|
- |
file_size,
|
| 201 |
|
- |
mime_type,
|
| 202 |
|
- |
blob_hash: hash,
|
| 203 |
|
- |
source_email_id: None,
|
| 204 |
|
- |
},
|
| 205 |
|
- |
);
|
| 206 |
|
- |
|
| 207 |
|
- |
let attachment = match create_result {
|
| 208 |
|
- |
Ok(a) => a,
|
| 209 |
|
- |
Err(e) => {
|
|
254 |
+ |
state
|
|
255 |
+ |
.attachments
|
|
256 |
+ |
.create(
|
|
257 |
+ |
DESKTOP_USER_ID,
|
|
258 |
+ |
NewAttachment {
|
|
259 |
+ |
task_id,
|
|
260 |
+ |
project_id,
|
|
261 |
+ |
filename,
|
|
262 |
+ |
file_size,
|
|
263 |
+ |
mime_type,
|
|
264 |
+ |
blob_hash: hash,
|
|
265 |
+ |
source_email_id: None,
|
|
266 |
+ |
},
|
|
267 |
+ |
)
|
|
268 |
+ |
.map_err(|error| {
|
| 210 |
269 |
|
// The row never landed; reclaim the blob we just wrote so it doesn't
|
| 211 |
270 |
|
// leak. Only remove a blob we created this call (dedup hits must stay).
|
| 212 |
271 |
|
if wrote_new_blob {
|
| 213 |
272 |
|
let _ = std::fs::remove_file(&blob_path);
|
| 214 |
273 |
|
}
|
| 215 |
|
- |
return Err(e.into());
|
| 216 |
|
- |
}
|
| 217 |
|
- |
};
|
|
274 |
+ |
AttachFailure::Failed(error.to_string())
|
|
275 |
+ |
})
|
|
276 |
+ |
}
|
|
277 |
+ |
|
|
278 |
+ |
/// Copy one blob out to the temp spool under its own filename, and say where.
|
|
279 |
+ |
///
|
|
280 |
+ |
/// What both open paths do before handing a file to something else, and what
|
|
281 |
+ |
/// the described dashboard's open route answers with as a `file://` address.
|
|
282 |
+ |
/// Sync for the reason [`attach_path`] is; the commands wrap it.
|
|
283 |
+ |
///
|
|
284 |
+ |
/// The spool is keyed by hash prefix so two attachments with the same filename
|
|
285 |
+ |
/// do not overwrite each other, and both the directory and the file are made
|
|
286 |
+ |
/// owner-only before the bytes land: the system temp dir is world-readable and
|
|
287 |
+ |
/// a decrypted blob must not be.
|
|
288 |
+ |
pub(crate) fn spool(
|
|
289 |
+ |
data_dir: &Path,
|
|
290 |
+ |
blob_hash: &str,
|
|
291 |
+ |
filename: &str,
|
|
292 |
+ |
) -> Result<PathBuf, AttachFailure> {
|
|
293 |
+ |
if !is_valid_blob_hash(blob_hash) {
|
|
294 |
+ |
return Err(AttachFailure::Refused(
|
|
295 |
+ |
"Invalid attachment reference".to_owned(),
|
|
296 |
+ |
));
|
|
297 |
+ |
}
|
|
298 |
+ |
let blob = blob_path(data_dir, blob_hash);
|
|
299 |
+ |
if !blob.exists() {
|
|
300 |
+ |
return Err(AttachFailure::Refused(
|
|
301 |
+ |
"Blob not available locally; sync required".to_owned(),
|
|
302 |
+ |
));
|
|
303 |
+ |
}
|
|
304 |
+ |
|
|
305 |
+ |
let hash_prefix = &blob_hash[..8];
|
|
306 |
+ |
let temp_dir = std::env::temp_dir()
|
|
307 |
+ |
.join("goingson-attachments")
|
|
308 |
+ |
.join(hash_prefix);
|
|
309 |
+ |
std::fs::create_dir_all(&temp_dir)
|
|
310 |
+ |
.map_err(|error| AttachFailure::Failed(format!("Failed to create temp dir: {error}")))?;
|
|
311 |
+ |
// Owner-only before the copy: the decrypted blob must not be readable by
|
|
312 |
+ |
// other local users via the world-readable system temp dir.
|
|
313 |
+ |
super::harden_temp_dir(&temp_dir);
|
|
314 |
+ |
|
|
315 |
+ |
let temp_path = temp_dir.join(safe_filename(filename));
|
|
316 |
+ |
std::fs::copy(&blob, &temp_path)
|
|
317 |
+ |
.map_err(|error| AttachFailure::Failed(format!("Failed to copy file: {error}")))?;
|
|
318 |
+ |
super::harden_temp_file(&temp_path);
|
|
319 |
+ |
|
|
320 |
+ |
Ok(temp_path)
|
|
321 |
+ |
}
|
|
322 |
+ |
|
|
323 |
+ |
/// A filename with everything that could leave the spool directory taken out.
|
|
324 |
+ |
fn safe_filename(filename: &str) -> String {
|
|
325 |
+ |
let safe: String = filename
|
|
326 |
+ |
.replace(['/', '\\'], "_")
|
|
327 |
+ |
.replace("..", "_")
|
|
328 |
+ |
.chars()
|
|
329 |
+ |
.filter(|c| !c.is_control())
|
|
330 |
+ |
.collect();
|
|
331 |
+ |
if safe.is_empty() {
|
|
332 |
+ |
"attachment".to_owned()
|
|
333 |
+ |
} else {
|
|
334 |
+ |
safe
|
|
335 |
+ |
}
|
|
336 |
+ |
}
|
|
337 |
+ |
|
|
338 |
+ |
// Commands
|
|
339 |
+ |
|
|
340 |
+ |
/// Attach a file to a task or project.
|
|
341 |
+ |
///
|
|
342 |
+ |
/// The work is [`attach_path`], on the blocking pool so a large attachment
|
|
343 |
+ |
/// doesn't stall the async reactor (ultra-fuzz Run #27 Perf S1). The
|
|
344 |
+ |
/// `file_path` comes from the JS file picker dialog.
|
|
345 |
+ |
#[tauri::command]
|
|
346 |
+ |
#[instrument(skip_all)]
|
|
347 |
+ |
pub async fn add_attachment(
|
|
348 |
+ |
state: State<'_, Arc<AppState>>,
|
|
349 |
+ |
task_id: Option<TaskId>,
|
|
350 |
+ |
project_id: Option<ProjectId>,
|
|
351 |
+ |
file_path: String,
|
|
352 |
+ |
) -> Result<AttachmentResponse, ApiError> {
|
|
353 |
+ |
let owned = Arc::clone(&state);
|
|
354 |
+ |
let attachment =
|
|
355 |
+ |
tokio::task::spawn_blocking(move || attach_path(&owned, task_id, project_id, &file_path))
|
|
356 |
+ |
.await
|
|
357 |
+ |
.map_err(|e| ApiError::internal(format!("Attachment task panicked: {e}")))??;
|
| 218 |
358 |
|
|
| 219 |
359 |
|
Ok(to_response(attachment, &state.data_dir))
|
| 220 |
360 |
|
}
|
| 268 |
408 |
|
.get_by_id(id, DESKTOP_USER_ID)?
|
| 269 |
409 |
|
.or_not_found("attachment", id)?;
|
| 270 |
410 |
|
|
| 271 |
|
- |
if !is_valid_blob_hash(&attachment.blob_hash) {
|
| 272 |
|
- |
return Err(ApiError::bad_request("Invalid attachment reference"));
|
| 273 |
|
- |
}
|
| 274 |
|
- |
let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash);
|
| 275 |
|
- |
if !blob_path.exists() {
|
| 276 |
|
- |
return Err(ApiError::bad_request(
|
| 277 |
|
- |
"Blob not available locally; sync required",
|
| 278 |
|
- |
));
|
| 279 |
|
- |
}
|
| 280 |
|
- |
|
| 281 |
|
- |
// Create a temp directory keyed by blob hash so different attachments with the same
|
| 282 |
|
- |
// filename don't overwrite each other.
|
| 283 |
|
- |
let hash_prefix = if attachment.blob_hash.len() >= 8 {
|
| 284 |
|
- |
&attachment.blob_hash[..8]
|
| 285 |
|
- |
} else {
|
| 286 |
|
- |
&attachment.blob_hash
|
| 287 |
|
- |
};
|
| 288 |
|
- |
let temp_dir = std::env::temp_dir()
|
| 289 |
|
- |
.join("goingson-attachments")
|
| 290 |
|
- |
.join(hash_prefix);
|
| 291 |
|
- |
std::fs::create_dir_all(&temp_dir)
|
| 292 |
|
- |
.map_api_err("Failed to create temp dir", ApiError::internal)?;
|
| 293 |
|
- |
// Owner-only before the copy: the decrypted blob must not be readable by
|
| 294 |
|
- |
// other local users via the world-readable system temp dir.
|
| 295 |
|
- |
super::harden_temp_dir(&temp_dir);
|
| 296 |
|
- |
|
| 297 |
|
- |
// Sanitize filename: strip path separators, .., and control characters to prevent path traversal
|
| 298 |
|
- |
let safe_name: String = attachment
|
| 299 |
|
- |
.filename
|
| 300 |
|
- |
.replace(['/', '\\'], "_")
|
| 301 |
|
- |
.replace("..", "_")
|
| 302 |
|
- |
.chars()
|
| 303 |
|
- |
.filter(|c| !c.is_control())
|
| 304 |
|
- |
.collect();
|
| 305 |
|
- |
let safe_name = if safe_name.is_empty() {
|
| 306 |
|
- |
"attachment".to_string()
|
| 307 |
|
- |
} else {
|
| 308 |
|
- |
safe_name
|
| 309 |
|
- |
};
|
| 310 |
|
- |
let temp_path = temp_dir.join(&safe_name);
|
| 311 |
|
- |
// Copy blob to temp with original filename (overwrite if exists). On the blocking
|
| 312 |
|
- |
// pool, a large attachment copy must not stall the reactor (Perf S1).
|
| 313 |
|
- |
copy_blocking(blob_path, temp_path.clone()).await?;
|
| 314 |
|
- |
super::harden_temp_file(&temp_path);
|
|
411 |
+ |
// On the blocking pool, a large attachment copy must not stall the reactor
|
|
412 |
+ |
// (Perf S1).
|
|
413 |
+ |
let data_dir = state.data_dir.clone();
|
|
414 |
+ |
let temp_path = tokio::task::spawn_blocking(move || {
|
|
415 |
+ |
spool(&data_dir, &attachment.blob_hash, &attachment.filename)
|
|
416 |
+ |
})
|
|
417 |
+ |
.await
|
|
418 |
+ |
.map_err(|e| ApiError::internal(format!("Spool task panicked: {e}")))?
|
|
419 |
+ |
.map_err(AttachFailure::bad_request)?;
|
| 315 |
420 |
|
|
| 316 |
421 |
|
open::that(&temp_path).map_api_err("Failed to open file", ApiError::internal)?;
|
| 317 |
422 |
|
|
| 465 |
570 |
|
blob_hash: String,
|
| 466 |
571 |
|
filename: String,
|
| 467 |
572 |
|
) -> Result<(), ApiError> {
|
| 468 |
|
- |
if !is_valid_blob_hash(&blob_hash) {
|
| 469 |
|
- |
return Err(ApiError::bad_request("Invalid attachment reference"));
|
| 470 |
|
- |
}
|
| 471 |
|
- |
let blob_path = state.data_dir.join("blobs").join(&blob_hash);
|
| 472 |
|
- |
if !blob_path.exists() {
|
| 473 |
|
- |
return Err(ApiError::bad_request(
|
| 474 |
|
- |
"Attachment not available locally; sync required",
|
| 475 |
|
- |
));
|
| 476 |
|
- |
}
|
| 477 |
|
- |
|
| 478 |
|
- |
let hash_prefix = if blob_hash.len() >= 8 {
|
| 479 |
|
- |
&blob_hash[..8]
|
| 480 |
|
- |
} else {
|
| 481 |
|
- |
&blob_hash
|
| 482 |
|
- |
};
|
| 483 |
|
- |
let temp_dir = std::env::temp_dir()
|
| 484 |
|
- |
.join("goingson-attachments")
|
| 485 |
|
- |
.join(hash_prefix);
|
| 486 |
|
- |
std::fs::create_dir_all(&temp_dir)
|
| 487 |
|
- |
.map_api_err("Failed to create temp dir", ApiError::internal)?;
|
| 488 |
|
- |
super::harden_temp_dir(&temp_dir);
|
| 489 |
|
- |
|
| 490 |
|
- |
let safe_name: String = filename
|
| 491 |
|
- |
.replace(['/', '\\'], "_")
|
| 492 |
|
- |
.replace("..", "_")
|
| 493 |
|
- |
.chars()
|
| 494 |
|
- |
.filter(|c| !c.is_control())
|
| 495 |
|
- |
.collect();
|
| 496 |
|
- |
let safe_name = if safe_name.is_empty() {
|
| 497 |
|
- |
"attachment".to_string()
|
| 498 |
|
- |
} else {
|
| 499 |
|
- |
safe_name
|
| 500 |
|
- |
};
|
| 501 |
|
- |
let temp_path = temp_dir.join(&safe_name);
|
| 502 |
|
- |
|
| 503 |
|
- |
copy_blocking(blob_path, temp_path.clone()).await?;
|
| 504 |
|
- |
super::harden_temp_file(&temp_path);
|
|
573 |
+ |
let data_dir = state.data_dir.clone();
|
|
574 |
+ |
let temp_path = tokio::task::spawn_blocking(move || spool(&data_dir, &blob_hash, &filename))
|
|
575 |
+ |
.await
|
|
576 |
+ |
.map_err(|e| ApiError::internal(format!("Spool task panicked: {e}")))?
|
|
577 |
+ |
.map_err(AttachFailure::bad_request)?;
|