w4c-workflows-api/Controllers/WorkflowFilesController.cs

416 lines
18 KiB
C#
Raw Permalink Normal View History

2026-09-01 22:12:21 +00:00
using Microsoft.AspNetCore.Mvc;
using w4c_workflows.Filters;
using w4c_workflows.Services;
namespace w4c_workflows.Controllers;
/// <summary>
/// Workflow file management endpoints. Provides CRUD operations for the
/// workflow YAML definitions and their sibling code files (shell scripts,
2026-09-02 19:14:59 +00:00
/// JS, Python, etc.) that live in the tenant's per-tenant workflow repository.
2026-09-01 22:12:21 +00:00
///
2026-09-02 19:14:59 +00:00
/// The repository is the single source of truth for a tenant's workflow content:
/// the workflows module edits files here and <c>POST /api/workflows/sync</c>
/// recompiles them (see <see cref="WorkflowSyncService"/>). Edits land in the
/// SAME directory <see cref="IWorkflowSource"/> reads from, so the "edit in the
/// workflows module" and "the engine runs it" paths are always coherent.
///
/// Works in both storage modes:
/// - <b>Forgejo-backed</b> (Forgejo:AdminToken set): files live in a private
/// per-user Forgejo repo <c>{login}/workflows-{login}</c>, cloned locally.
/// <c>/commit</c> and <c>/pull</c> sync to/from the remote.
/// - <b>Filesystem-only</b> (no AdminToken): files live under
/// <c>{CopiesRoot}/{tenantId}/</c> with no remote. <c>/commit</c> and
/// <c>/pull</c> are no-ops.
2026-09-01 22:12:21 +00:00
/// </summary>
[ApiController]
[Route("api/workflow-files")]
public class WorkflowFilesController : ControllerBase
{
public WorkflowFilesController(
WorkflowSourceFactory sourceFactory,
2026-09-03 14:44:39 +00:00
WorkflowRepoStore repoStore,
WorkflowSyncService sync,
2026-09-07 19:05:15 +00:00
RealtimeEventHub events,
2026-09-13 16:28:47 +00:00
GitRunner git,
2026-09-01 22:12:21 +00:00
ILogger<WorkflowFilesController> logger)
{
_sourceFactory = sourceFactory;
2026-09-03 14:44:39 +00:00
_repoStore = repoStore;
_sync = sync;
2026-09-07 19:05:15 +00:00
_events = events;
2026-09-13 16:28:47 +00:00
_git = git;
2026-09-01 22:12:21 +00:00
_logger = logger;
}
2026-09-03 14:44:39 +00:00
private readonly WorkflowSourceFactory _sourceFactory;
private readonly WorkflowRepoStore _repoStore;
private readonly WorkflowSyncService _sync;
2026-09-07 19:05:15 +00:00
private readonly RealtimeEventHub _events;
2026-09-13 16:28:47 +00:00
private readonly GitRunner _git;
2026-09-03 14:44:39 +00:00
private readonly ILogger<WorkflowFilesController> _logger;
2026-09-01 22:12:21 +00:00
private string TenantId => (string?)HttpContext.Items["TenantId"]
?? throw new InvalidOperationException("TenantId not resolved by auth middleware");
/// <summary>
2026-09-02 19:14:59 +00:00
/// The user's Forgejo login when the middleware resolved it (Forgejo-backed
/// mode succeeded). Null when in filesystem-only mode OR when the Forgejo
/// login could not be resolved (the request then falls back to the filesystem
/// source, so file CRUD must target the filesystem dir to stay consistent).
2026-09-01 22:12:21 +00:00
/// </summary>
2026-09-02 19:14:59 +00:00
private string? MaybeForgejoLogin =>
HttpContext.Items.TryGetValue("ForgejoLogin", out var v) ? v as string : null;
/// <summary>
/// The on-disk directory holding the tenant's workflow files. In Forgejo-backed
/// mode (login resolved) it is the per-user clone dir; otherwise it is the
/// per-tenant directory. Both are exactly the root <see cref="IWorkflowSource"/>
/// reads from, so file edits made through these endpoints are what get compiled
/// on sync.
/// </summary>
2026-09-03 14:44:39 +00:00
private string SourceDir => _sourceFactory.ResolveTenantSourceDir(
TenantId, MaybeForgejoLogin, CurrentRepo);
/// <summary>The tenant's current workflow repo name (set by the source middleware).</summary>
private string CurrentRepo =>
(HttpContext.Items["WorkflowRepo"] as string)
?? (_sourceFactory.Forgejo?.WorkflowRepoName ?? "workflows");
2026-09-01 22:12:21 +00:00
/// <summary>Returns the Forgejo repo info for this tenant's workflow files.</summary>
[HttpGet("repo")]
[RequireScope("read")]
public IActionResult GetRepoInfo()
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
2026-09-02 19:14:59 +00:00
var login = MaybeForgejoLogin;
if (string.IsNullOrWhiteSpace(login))
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
2026-09-01 22:12:21 +00:00
2026-09-03 14:44:39 +00:00
var repoName = _repoStore.DefaultName;
try { repoName = CurrentRepo; } catch { /* keep default */ }
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(TenantId, login, repoName);
var fullName = _sourceFactory.Forgejo.RepoFullNameForLogin(login, repoName);
2026-09-01 22:12:21 +00:00
var exists = Directory.Exists(Path.Combine(cloneDir, ".git"));
return Ok(new
{
2026-09-03 14:44:39 +00:00
repoName,
2026-09-01 22:12:21 +00:00
repoFullName = fullName,
cloneDir,
cloned = exists,
2026-09-02 19:14:59 +00:00
forgejoUrl = $"{_sourceFactory.Forgejo.ForgejoBaseUrl}/{fullName}",
2026-09-01 22:12:21 +00:00
});
}
2026-09-03 14:44:39 +00:00
/// <summary>
/// Sets the tenant's workflow repository (basename, e.g. <c>workflows</c> or
/// a user-selected repo) and re-syncs. Switching repos unloads workflows
/// compiled from any previously-selected repo (they are no longer listed).
/// </summary>
[HttpPut("repo")]
[RequireScope("manage")]
public async Task<IActionResult> SetRepo([FromBody] SetWorkflowRepoRequest? request, CancellationToken ct)
{
if (request == null || string.IsNullOrWhiteSpace(request.RepoName))
return BadRequest(new { error = "repoName is required" });
2026-09-12 18:45:33 +00:00
// A workflow repo is always owned by the caller's own Forgejo account, so
// only a single safe name segment is accepted (no owner prefix / traversal).
if (!WorkflowRepoStore.TryNormalizeName(request.RepoName, out var repoName))
return BadRequest(new { error = "Invalid repository name. Use a single repository name (letters, digits, '.', '_', '-')." });
var login = MaybeForgejoLogin;
// Materialize the selected repo BEFORE persisting the switch. An
// unreachable/missing repo must fail loudly instead of being saved as the
// tenant's selection (which would strand the workflows module on a dead repo).
if (_sourceFactory.Forgejo != null && !string.IsNullOrWhiteSpace(login))
{
try
{
await _sourceFactory.Forgejo.EnsureCloneAsync(TenantId, login, repoName, ct);
await _sourceFactory.Forgejo.PullAsync(TenantId, login, repoName, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Workflow repo switch for tenant {TenantId} to {Repo} failed", TenantId, repoName);
return BadRequest(new { error = $"Repository '{repoName}' could not be opened: {ex.Message}" });
}
}
2026-09-03 14:44:39 +00:00
await _repoStore.SetNameAsync(TenantId, repoName, ct);
// Re-sync workflows for the newly selected repo so the list reflects it.
var syncResult = await _sync.SyncAsync(TenantId, repoName, ct);
string? fullName = null, cloneDir = null;
if (_sourceFactory.Forgejo != null && !string.IsNullOrWhiteSpace(login))
{
cloneDir = _sourceFactory.Forgejo.TenantCloneDir(TenantId, login, repoName);
fullName = _sourceFactory.Forgejo.RepoFullNameForLogin(login, repoName);
}
return Ok(new
{
repoName,
repoFullName = fullName,
cloneDir,
compiled = syncResult.Compiled,
removed = syncResult.Removed,
errors = syncResult.Errors,
});
}
2026-09-01 22:12:21 +00:00
/// <summary>
2026-09-02 19:14:59 +00:00
/// Lists all files under a directory (recursive, excluding .git), plus the
/// top-level subdirectories. Returns repo-relative paths.
2026-09-01 22:12:21 +00:00
/// </summary>
[HttpGet]
[RequireScope("read")]
public IActionResult ListFiles([FromQuery] string? path = null)
{
2026-09-02 19:14:59 +00:00
var sourceDir = SourceDir;
if (!Directory.Exists(sourceDir))
2026-09-01 22:12:21 +00:00
return Ok(new { files = Array.Empty<object>() });
var dir = string.IsNullOrWhiteSpace(path)
2026-09-02 19:14:59 +00:00
? sourceDir
: Path.Combine(sourceDir, path.Replace('/', Path.DirectorySeparatorChar));
2026-09-01 22:12:21 +00:00
2026-09-02 19:14:59 +00:00
var fullDir = Path.GetFullPath(dir);
2026-09-12 18:45:33 +00:00
if (!IsWithin(sourceDir, fullDir))
2026-09-02 19:14:59 +00:00
return BadRequest(new { error = "Invalid path" });
if (!Directory.Exists(fullDir))
2026-09-01 22:12:21 +00:00
return NotFound(new { error = $"Directory not found: {path}" });
var files = new List<object>();
2026-09-02 19:14:59 +00:00
foreach (var full in Directory.EnumerateFiles(fullDir, "*", SearchOption.AllDirectories))
2026-09-01 22:12:21 +00:00
{
2026-09-02 19:14:59 +00:00
var rel = Path.GetRelativePath(sourceDir, full).Replace(Path.DirectorySeparatorChar, '/');
if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase))
continue;
2026-09-01 22:12:21 +00:00
var info = new FileInfo(full);
2026-09-02 19:14:59 +00:00
files.Add(new { path = rel, name = Path.GetFileName(full), size = info.Length, modified = info.LastWriteTimeUtc });
2026-09-01 22:12:21 +00:00
}
2026-09-02 19:14:59 +00:00
foreach (var full in Directory.EnumerateDirectories(fullDir))
2026-09-01 22:12:21 +00:00
{
var name = Path.GetFileName(full);
if (name == ".git") continue;
2026-09-02 19:14:59 +00:00
var rel = Path.GetRelativePath(sourceDir, full).Replace(Path.DirectorySeparatorChar, '/');
files.Add(new { path = rel, name, isDir = true });
2026-09-01 22:12:21 +00:00
}
return Ok(new { files });
}
/// <summary>Returns the content of a specific file.</summary>
[HttpGet("content")]
[RequireScope("read")]
public IActionResult GetContent([FromQuery] string path)
{
if (string.IsNullOrWhiteSpace(path))
return BadRequest(new { error = "path is required" });
2026-09-02 19:14:59 +00:00
var full = ResolveSafeFile(path, out var status);
if (status.HasValue)
2026-09-01 22:12:21 +00:00
return BadRequest(new { error = "Invalid path" });
if (!System.IO.File.Exists(full))
return NotFound(new { error = $"File not found: {path}" });
var content = System.IO.File.ReadAllText(full);
var info = new FileInfo(full);
return Ok(new { path, content, size = info.Length, modified = info.LastWriteTimeUtc });
}
/// <summary>
2026-09-02 19:14:59 +00:00
/// Saves content to a file in the tenant's workflow repo. Writes to the local
/// working tree (no commit/push — use POST /commit in Forgejo mode for that).
2026-09-01 22:12:21 +00:00
/// </summary>
[HttpPost("save")]
[RequireScope("manage")]
public IActionResult SaveFile([FromBody] SaveWorkflowFileRequest request)
{
if (string.IsNullOrWhiteSpace(request.Path))
return BadRequest(new { error = "path is required" });
2026-09-02 19:14:59 +00:00
var full = ResolveSafeFile(request.Path, out var status);
if (status.HasValue)
2026-09-01 22:12:21 +00:00
return BadRequest(new { error = "Invalid path" });
try
{
var dir = Path.GetDirectoryName(full);
if (dir != null && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
System.IO.File.WriteAllText(full, request.Content ?? string.Empty, new System.Text.UTF8Encoding(false));
2026-09-02 19:14:59 +00:00
_logger.LogInformation("Saved workflow file {Path} for tenant {TenantId} ({Login})",
request.Path, TenantId, MaybeForgejoLogin ?? "-");
2026-09-07 19:05:15 +00:00
// Notify any open workflow editor that the file changed server-side (frontend is optional:
// the AI can write it via the backend while no page is open, then the page reloads on next visit).
_events.Publish(TenantId, "workflow", request.Path, DateTime.UtcNow);
2026-09-01 22:12:21 +00:00
return Ok(new { success = true, path = request.Path });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save workflow file {Path}", request.Path);
return BadRequest(new { error = ex.Message });
}
}
2026-09-02 19:14:59 +00:00
/// <summary>
/// Deletes files/directories from the tenant's workflow repo. Used to remove a
/// whole workflow (its folder) or an individual file. Returns the paths deleted.
/// </summary>
[HttpPost("delete")]
[RequireScope("manage")]
public IActionResult DeleteFiles([FromBody] DeleteWorkflowFileRequest? request)
{
if (request == null || request.Paths == null || request.Paths.Length == 0)
return BadRequest(new { error = "paths is required" });
var sourceDir = SourceDir;
var fullSourceDir = Path.GetFullPath(sourceDir);
var deleted = new List<string>();
foreach (var raw in request.Paths)
{
if (string.IsNullOrWhiteSpace(raw)) continue;
var full = Path.GetFullPath(Path.Combine(sourceDir, raw.Replace('/', Path.DirectorySeparatorChar)));
2026-09-12 18:45:33 +00:00
if (full == fullSourceDir || !IsWithin(sourceDir, full))
2026-09-02 19:14:59 +00:00
continue;
if (Directory.Exists(full))
Directory.Delete(full, recursive: true);
else if (System.IO.File.Exists(full))
System.IO.File.Delete(full);
else
continue;
deleted.Add(raw);
}
_logger.LogInformation("Deleted workflow files for tenant {TenantId}: {Paths}",
TenantId, string.Join(", ", deleted));
return Ok(new { success = true, deleted });
}
/// <summary>Returns the git status of the tenant's workflow repo (Forgejo-backed mode only).</summary>
2026-09-01 22:12:21 +00:00
[HttpGet("status")]
[RequireScope("read")]
2026-09-13 16:28:47 +00:00
public async Task<IActionResult> GetStatus(CancellationToken ct)
2026-09-01 22:12:21 +00:00
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
2026-09-02 19:14:59 +00:00
var login = MaybeForgejoLogin;
if (string.IsNullOrWhiteSpace(login))
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
2026-09-01 22:12:21 +00:00
2026-09-03 14:44:39 +00:00
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(TenantId, login, CurrentRepo);
2026-09-01 22:12:21 +00:00
if (!Directory.Exists(Path.Combine(cloneDir, ".git")))
return Ok(new { branch = "", changed = Array.Empty<object>(), untracked = Array.Empty<object>() });
2026-09-13 16:28:47 +00:00
var branch = (await _git.RunAsync(cloneDir, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, ct)).TrimmedStdOut ?? "";
var status = await _git.RunAsync(cloneDir, new[] { "status", "--porcelain" }, ct);
var statusOutput = status.Succeeded ? status.StdOut : "";
2026-09-01 22:12:21 +00:00
var changed = new List<object>();
var untracked = new List<object>();
foreach (var line in statusOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (line.Length < 4) continue;
var statusCode = line[..2].Trim();
var filePath = line[3..].Trim();
if (statusCode == "??")
untracked.Add(new { path = filePath, status = "untracked" });
else
changed.Add(new { path = filePath, status = statusCode });
}
2026-09-13 16:28:47 +00:00
var head = (await _git.RunAsync(cloneDir, new[] { "rev-parse", "--short", "HEAD" }, ct)).TrimmedStdOut ?? "";
2026-09-01 22:12:21 +00:00
return Ok(new { branch, head, changed, untracked });
}
/// <summary>
2026-09-02 19:14:59 +00:00
/// Commits and pushes all changes to Forgejo. A no-op (returns success) in
/// filesystem-only mode. After committing, call POST /api/workflows/sync to
/// recompile.
2026-09-01 22:12:21 +00:00
/// </summary>
[HttpPost("commit")]
[RequireScope("manage")]
public async Task<IActionResult> CommitAndPush([FromBody] CommitWorkflowRequest? request = null)
{
if (_sourceFactory.Forgejo == null)
2026-09-02 19:14:59 +00:00
return Ok(new { success = true, output = "filesystem mode — no remote to push" });
var login = MaybeForgejoLogin;
if (string.IsNullOrWhiteSpace(login))
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
2026-09-01 22:12:21 +00:00
var message = string.IsNullOrWhiteSpace(request?.Message)
? $"Update workflow files ({DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC)"
: request!.Message;
var (success, output) = await _sourceFactory.Forgejo.CommitAndPushAsync(
2026-09-12 18:45:33 +00:00
TenantId, login, message, CurrentRepo, HttpContext.RequestAborted);
2026-09-01 22:12:21 +00:00
_logger.LogInformation("Commit/push for login {Login}: {Success} ({Output})",
2026-09-02 19:14:59 +00:00
login, success, output?.Trim() ?? "");
2026-09-01 22:12:21 +00:00
return Ok(new { success, output });
}
2026-09-02 19:14:59 +00:00
/// <summary>Pulls the latest changes from Forgejo (no-op in filesystem-only mode).</summary>
2026-09-01 22:12:21 +00:00
[HttpPost("pull")]
[RequireScope("manage")]
public async Task<IActionResult> Pull()
{
if (_sourceFactory.Forgejo == null)
2026-09-02 19:14:59 +00:00
return Ok(new { success = true, output = "filesystem mode — no remote to pull" });
var login = MaybeForgejoLogin;
if (string.IsNullOrWhiteSpace(login))
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
2026-09-01 22:12:21 +00:00
2026-09-12 18:45:33 +00:00
var ok = await _sourceFactory.Forgejo.PullAsync(TenantId, login, CurrentRepo, HttpContext.RequestAborted);
2026-09-01 22:12:21 +00:00
return Ok(new { success = ok });
}
2026-09-02 19:14:59 +00:00
/// <summary>Resolves a repo-relative path to an absolute path inside the tenant source dir.</summary>
private string ResolveSafeFile(string path, out int? status)
{
var sourceDir = SourceDir;
var full = Path.GetFullPath(Path.Combine(sourceDir, path.Replace('/', Path.DirectorySeparatorChar)));
2026-09-12 18:45:33 +00:00
if (!IsWithin(sourceDir, full))
2026-09-02 19:14:59 +00:00
{
status = 400;
return full;
}
status = null;
return full;
}
2026-09-12 18:45:33 +00:00
/// <summary>
/// True when <paramref name="candidate"/> is the root directory or a path
/// inside it. Uses a trailing separator so a sibling directory whose name
/// merely shares the root's prefix (e.g. ".../workflows-evil") can't escape.
/// </summary>
private static bool IsWithin(string root, string candidate)
{
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar);
var fullCandidate = Path.GetFullPath(candidate);
if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal))
return true;
return fullCandidate.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal);
}
2026-09-01 22:12:21 +00:00
}
2026-09-03 14:44:39 +00:00
public sealed record SetWorkflowRepoRequest(string? RepoName);
2026-09-01 22:12:21 +00:00
public sealed record SaveWorkflowFileRequest(string Path, string? Content);
public sealed record CommitWorkflowRequest(string? Message);
2026-09-02 19:14:59 +00:00
public sealed record DeleteWorkflowFileRequest(string[] Paths);