w4c-workflows-api/Controllers/WorkflowFilesController.cs

279 lines
11 KiB
C#
Raw Normal View History

2026-09-01 22:12:21 +00:00
using System.Diagnostics;
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,
/// JS, Python, etc.) stored in the tenant's private Forgejo repo.
///
/// Requires the Forgejo-backed mode to be active (<c>Forgejo:AdminToken</c>
/// configured). In filesystem-only mode these endpoints return 503.
/// </summary>
[ApiController]
[Route("api/workflow-files")]
public class WorkflowFilesController : ControllerBase
{
private readonly WorkflowSourceFactory _sourceFactory;
private readonly ILogger<WorkflowFilesController> _logger;
public WorkflowFilesController(
WorkflowSourceFactory sourceFactory,
ILogger<WorkflowFilesController> logger)
{
_sourceFactory = sourceFactory;
_logger = logger;
}
private string TenantId => (string?)HttpContext.Items["TenantId"]
?? throw new InvalidOperationException("TenantId not resolved by auth middleware");
/// <summary>
/// The user's Forgejo login, resolved from the tenant ID by the
/// WorkflowSourceMiddleware. Used for repo path lookups since repos
/// are per-user: <c>{login}/workflows-{login}</c>.
/// </summary>
private string ForgejoLogin => (string?)HttpContext.Items["ForgejoLogin"]
?? throw new InvalidOperationException("ForgejoLogin not resolved by WorkflowSourceMiddleware");
/// <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." });
var login = ForgejoLogin;
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(login);
var fullName = ForgejoWorkflowRepoService.RepoFullNameForLogin(login);
var exists = Directory.Exists(Path.Combine(cloneDir, ".git"));
return Ok(new
{
repoFullName = fullName,
cloneDir,
cloned = exists,
forgejoUrl = $"{_sourceFactory.Forgejo.GetType().GetProperty("ForgejoBase")?.GetValue(_sourceFactory.Forgejo) ?? "https://forgejo.wiz4chat.com"}/{fullName}",
});
}
/// <summary>
/// Lists all files in the tenant's workflow repo (recursive, excluding .git).
/// Returns relative paths and basic file info.
/// </summary>
[HttpGet]
[RequireScope("read")]
public IActionResult ListFiles([FromQuery] string? path = null)
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
if (!Directory.Exists(cloneDir))
return Ok(new { files = Array.Empty<object>() });
var dir = string.IsNullOrWhiteSpace(path)
? cloneDir
: Path.Combine(cloneDir, path.Replace('/', Path.DirectorySeparatorChar));
if (!Directory.Exists(dir))
return NotFound(new { error = $"Directory not found: {path}" });
var files = new List<object>();
foreach (var full in Directory.EnumerateFiles(dir))
{
var name = Path.GetFileName(full);
var rel = Path.GetRelativePath(cloneDir, full).Replace(Path.DirectorySeparatorChar, '/');
var info = new FileInfo(full);
files.Add(new { name, path = rel, size = info.Length, modified = info.LastWriteTimeUtc });
}
foreach (var full in Directory.EnumerateDirectories(dir))
{
var name = Path.GetFileName(full);
if (name == ".git") continue;
var rel = Path.GetRelativePath(cloneDir, full).Replace(Path.DirectorySeparatorChar, '/');
files.Add(new { name, path = rel, isDir = true });
}
return Ok(new { files });
}
/// <summary>Returns the content of a specific file.</summary>
[HttpGet("content")]
[RequireScope("read")]
public IActionResult GetContent([FromQuery] string path)
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
if (string.IsNullOrWhiteSpace(path))
return BadRequest(new { error = "path is required" });
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
var full = Path.Combine(cloneDir, path.Replace('/', Path.DirectorySeparatorChar));
// Reject path traversal.
var fullCloneDir = Path.GetFullPath(cloneDir);
var fullFile = Path.GetFullPath(full);
if (!fullFile.StartsWith(fullCloneDir, StringComparison.Ordinal))
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>
/// Saves content to a file in the tenant's workflow repo.
/// Writes to the local clone (does not commit/push — use POST /commit for that).
/// </summary>
[HttpPost("save")]
[RequireScope("manage")]
public IActionResult SaveFile([FromBody] SaveWorkflowFileRequest request)
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
if (string.IsNullOrWhiteSpace(request.Path))
return BadRequest(new { error = "path is required" });
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
var full = Path.Combine(cloneDir, request.Path.Replace('/', Path.DirectorySeparatorChar));
// Reject path traversal.
var fullCloneDir = Path.GetFullPath(cloneDir);
var fullFile = Path.GetFullPath(full);
if (!fullFile.StartsWith(fullCloneDir, StringComparison.Ordinal))
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));
_logger.LogInformation("Saved workflow file {Path} for user {Login}", request.Path, ForgejoLogin);
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 });
}
}
/// <summary>Returns the git status of the tenant's workflow repo.</summary>
[HttpGet("status")]
[RequireScope("read")]
public IActionResult GetStatus()
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
if (!Directory.Exists(Path.Combine(cloneDir, ".git")))
return Ok(new { branch = "", changed = Array.Empty<object>(), untracked = Array.Empty<object>() });
var branch = RunGit(cloneDir, "rev-parse", "--abbrev-ref", "HEAD")?.Trim() ?? "";
var statusOutput = RunGit(cloneDir, "status", "--porcelain") ?? "";
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 });
}
var head = RunGit(cloneDir, "rev-parse", "--short", "HEAD")?.Trim() ?? "";
return Ok(new { branch, head, changed, untracked });
}
/// <summary>
/// Commits all changes and pushes to Forgejo. Optionally provides a commit message.
/// After committing, call POST /api/workflows/sync to recompile.
/// </summary>
[HttpPost("commit")]
[RequireScope("manage")]
public async Task<IActionResult> CommitAndPush([FromBody] CommitWorkflowRequest? request = null)
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
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(
ForgejoLogin, message, HttpContext.RequestAborted);
_logger.LogInformation("Commit/push for login {Login}: {Success} ({Output})",
ForgejoLogin, success, output?.Trim() ?? "");
return Ok(new { success, output });
}
/// <summary>Pulls the latest changes from Forgejo.</summary>
[HttpPost("pull")]
[RequireScope("manage")]
public async Task<IActionResult> Pull()
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
var ok = await _sourceFactory.Forgejo.PullAsync(ForgejoLogin, HttpContext.RequestAborted);
return Ok(new { success = ok });
}
private string? RunGit(string workDir, params string[] args)
{
try
{
var psi = new ProcessStartInfo("git")
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var arg in args) psi.ArgumentList.Add(arg);
using var process = Process.Start(psi);
if (process == null) return null;
var output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(5000))
{
process.Kill(entireProcessTree: true);
return null;
}
return process.ExitCode == 0 ? output.Trim() : null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "git invocation failed in {Dir}", workDir);
return null;
}
}
}
public sealed record SaveWorkflowFileRequest(string Path, string? Content);
public sealed record CommitWorkflowRequest(string? Message);