- SlugGuard.IsValid() rejects slugs with path traversal (../, //, uppercase) - SlugGuard.ToSafePath() ensures sync writes stay within target directory - PushCommand: skip files with invalid slugs before upload - SyncCommand: validate server-returned slugs before writing to disk - EditCommand/NewCommand: try/finally guarantees temp file deletion on crash - NotesCache: set 600 permissions on cache file (metadata only, no token) - NewCommand: remove dead Sha256 method (HashHelper already covers this)
119 lines
4.1 KiB
C#
119 lines
4.1 KiB
C#
using System.Diagnostics;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using NozCli.Repl;
|
|
using Spectre.Console;
|
|
|
|
namespace NozCli.Commands;
|
|
|
|
public class EditCommand : ICommand
|
|
{
|
|
public string Name => "edit";
|
|
public string Description => "Open a note in nano (or -c for VS Code)";
|
|
public string Usage => "edit [-c] <slug>";
|
|
|
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
|
{
|
|
var useCode = args.Contains("-c") || args.Contains("--code");
|
|
var slug = args.FirstOrDefault(a => !a.StartsWith('-'));
|
|
|
|
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: edit [-c] <slug>[/]"); return; }
|
|
|
|
slug = ctx.ResolveSlug(slug);
|
|
|
|
NozCli.Core.Models.NoteContent note = default!;
|
|
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
|
|
note = await ctx.Api.GetNoteAsync(slug, ct));
|
|
|
|
var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md");
|
|
await File.WriteAllTextAsync(tmp, note.Markdown);
|
|
|
|
string edited;
|
|
try
|
|
{
|
|
var (editor, editorArgs) = useCode
|
|
? ResolveCode(tmp, ctx.Config.CodeEditor)
|
|
: ResolveTerminal(tmp, ctx.Config.Editor);
|
|
|
|
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]");
|
|
var proc = Process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = editor,
|
|
Arguments = editorArgs,
|
|
UseShellExecute = false,
|
|
});
|
|
proc?.WaitForExit();
|
|
|
|
edited = await File.ReadAllTextAsync(tmp);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(tmp)) File.Delete(tmp);
|
|
}
|
|
|
|
var newHash = Sha256(edited);
|
|
|
|
if (newHash == note.Hash)
|
|
{
|
|
AnsiConsole.MarkupLine("[dim]No changes.[/]");
|
|
return;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[green]Changes detected.[/]");
|
|
var push = AnsiConsole.Confirm("Push changes to server?");
|
|
if (!push) { AnsiConsole.MarkupLine("[dim]Discarded.[/]"); return; }
|
|
|
|
await AnsiConsole.Status().StartAsync("Pushing…", async _ =>
|
|
{
|
|
await ctx.Api.PutNoteAsync(slug, edited, ct);
|
|
ctx.NoteCache = await ctx.Api.GetNotesAsync(ct);
|
|
});
|
|
|
|
AnsiConsole.MarkupLine($"[green]✓[/] {slug} updated");
|
|
}
|
|
|
|
// nano (or configured terminal editor) — quick edits
|
|
internal static (string, string) ResolveTerminal(string filePath, string? configured)
|
|
{
|
|
var editor = configured
|
|
?? Environment.GetEnvironmentVariable("EDITOR")
|
|
?? (IsOnPath("nano") ? "nano" : IsOnPath("vi") ? "vi" : "nano");
|
|
return (editor, $"\"{filePath}\"");
|
|
}
|
|
|
|
// -c editor: configured, or auto-detect code/zed/etc.
|
|
// GUI editors that need a --wait flag are detected by name.
|
|
internal static (string, string) ResolveCode(string filePath, string? configured)
|
|
{
|
|
var editor = configured
|
|
?? (IsOnPath("code") ? "code" :
|
|
IsOnPath("code-insiders")? "code-insiders":
|
|
IsOnPath("zed") ? "zed" :
|
|
IsOnPath("subl") ? "subl" : "code");
|
|
|
|
// These editors need --wait to block until the file is closed
|
|
var needsWait = editor is "code" or "code-insiders" or "subl";
|
|
var fileArg = $"\"{filePath}\"";
|
|
return (editor, needsWait ? $"--wait {fileArg}" : fileArg);
|
|
}
|
|
|
|
internal static bool IsOnPath(string name)
|
|
{
|
|
try
|
|
{
|
|
using var p = Process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = OperatingSystem.IsWindows() ? "where" : "which",
|
|
Arguments = name,
|
|
RedirectStandardOutput = true,
|
|
UseShellExecute = false,
|
|
});
|
|
p?.WaitForExit();
|
|
return p?.ExitCode == 0;
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
private static string Sha256(string content) =>
|
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))).ToLower();
|
|
}
|