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,
|
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
|
|
|
|
|
{
|
|
|
|
|
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>
|
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>
|
|
|
|
|
private string SourceDir => _sourceFactory.ResolveTenantSourceDir(TenantId, MaybeForgejoLogin);
|
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
|
|
|
|
|
|
|
|
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,
|
2026-09-02 19:14:59 +00:00
|
|
|
forgejoUrl = $"{_sourceFactory.Forgejo.ForgejoBaseUrl}/{fullName}",
|
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 fullSourceDir = Path.GetFullPath(sourceDir);
|
|
|
|
|
var fullDir = Path.GetFullPath(dir);
|
|
|
|
|
if (!fullDir.StartsWith(fullSourceDir, StringComparison.Ordinal))
|
|
|
|
|
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-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)));
|
|
|
|
|
if (!full.StartsWith(fullSourceDir, StringComparison.Ordinal) ||
|
|
|
|
|
full == fullSourceDir)
|
|
|
|
|
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")]
|
|
|
|
|
public IActionResult GetStatus()
|
|
|
|
|
{
|
|
|
|
|
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-02 19:14:59 +00:00
|
|
|
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(login);
|
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>() });
|
|
|
|
|
|
|
|
|
|
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>
|
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-02 19:14:59 +00:00
|
|
|
login, message, 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-02 19:14:59 +00:00
|
|
|
var ok = await _sourceFactory.Forgejo.PullAsync(login, 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)));
|
|
|
|
|
var fullSourceDir = Path.GetFullPath(sourceDir);
|
|
|
|
|
if (!full.StartsWith(fullSourceDir, StringComparison.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
status = 400;
|
|
|
|
|
return full;
|
|
|
|
|
}
|
|
|
|
|
status = null;
|
|
|
|
|
return full;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 22:12:21 +00:00
|
|
|
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);
|
2026-09-02 19:14:59 +00:00
|
|
|
public sealed record DeleteWorkflowFileRequest(string[] Paths);
|