TaskRunner appends attached files (absolute paths) to the run prompt as the read-only Reference files section. Task and list deletes now remove the on-disk attachment dir eagerly, and a startup AttachmentOrphanRecovery sweep drops any attachments/<taskId>/ whose task no longer exists (covers list cascade and planning-discard paths).
86 lines
3.2 KiB
C#
86 lines
3.2 KiB
C#
namespace ClaudeDo.Data;
|
|
|
|
public sealed class AttachmentStore
|
|
{
|
|
private const long MaxBytes = 5 * 1024 * 1024; // 5 MB
|
|
|
|
private readonly string _root;
|
|
|
|
public AttachmentStore(string? root = null)
|
|
=> _root = root ?? Paths.Expand("~/.todo-app/attachments");
|
|
|
|
public string Root => _root;
|
|
|
|
public IReadOnlyList<string> EnumerateTaskIds()
|
|
{
|
|
if (!Directory.Exists(_root)) return Array.Empty<string>();
|
|
return Directory.GetDirectories(_root)
|
|
.Select(Path.GetFileName)
|
|
.Where(n => n is not null)
|
|
.Select(n => n!)
|
|
.ToList();
|
|
}
|
|
|
|
public string TaskDir(string taskId)
|
|
=> Path.Combine(_root, taskId);
|
|
|
|
public async Task<long> SaveAsync(string taskId, string fileName, Stream content, CancellationToken ct = default)
|
|
{
|
|
if (Path.GetFileName(fileName) != fileName)
|
|
throw new ArgumentException("fileName must not contain path separators or '..'.", nameof(fileName));
|
|
|
|
var dir = TaskDir(taskId);
|
|
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
|
|
|
|
// Containment guard: resolved path must stay inside TaskDir
|
|
var resolvedDir = Path.GetFullPath(dir);
|
|
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
|
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
|
|
throw new ArgumentException("fileName resolves outside the task directory.", nameof(fileName));
|
|
|
|
Directory.CreateDirectory(dir);
|
|
|
|
// Buffer up to MaxBytes + 1 to detect oversize without reading fully
|
|
await using var fs = new FileStream(resolvedPath, FileMode.Create, FileAccess.Write, FileShare.None,
|
|
bufferSize: 81920, useAsync: true);
|
|
|
|
var buffer = new byte[81920];
|
|
long total = 0;
|
|
int read;
|
|
while ((read = await content.ReadAsync(buffer, ct)) > 0)
|
|
{
|
|
total += read;
|
|
if (total > MaxBytes)
|
|
{
|
|
fs.Close();
|
|
try { File.Delete(resolvedPath); } catch { }
|
|
throw new InvalidOperationException($"Attachment exceeds the 5 MB size limit.");
|
|
}
|
|
await fs.WriteAsync(buffer.AsMemory(0, read), ct);
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
public void DeleteFile(string taskId, string fileName)
|
|
{
|
|
if (Path.GetFileName(fileName) != fileName)
|
|
return; // traversal attempt — ignore silently
|
|
|
|
var dir = TaskDir(taskId);
|
|
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
|
|
var resolvedDir = Path.GetFullPath(dir);
|
|
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
|
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
|
|
return; // containment violation — ignore silently
|
|
|
|
try { File.Delete(resolvedPath); } catch (DirectoryNotFoundException) { } catch (FileNotFoundException) { }
|
|
}
|
|
|
|
public void DeleteTaskDir(string taskId)
|
|
{
|
|
var dir = TaskDir(taskId);
|
|
try { Directory.Delete(dir, recursive: true); } catch (DirectoryNotFoundException) { } catch (IOException) { }
|
|
}
|
|
}
|