using System.Diagnostics; using Microsoft.AspNetCore.Mvc; using w4c_workflows.Filters; using w4c_workflows.Services; namespace w4c_workflows.Controllers; /// /// 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 (Forgejo:AdminToken /// configured). In filesystem-only mode these endpoints return 503. /// [ApiController] [Route("api/workflow-files")] public class WorkflowFilesController : ControllerBase { private readonly WorkflowSourceFactory _sourceFactory; private readonly ILogger _logger; public WorkflowFilesController( WorkflowSourceFactory sourceFactory, ILogger logger) { _sourceFactory = sourceFactory; _logger = logger; } private string TenantId => (string?)HttpContext.Items["TenantId"] ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); /// /// The user's Forgejo login, resolved from the tenant ID by the /// WorkflowSourceMiddleware. Used for repo path lookups since repos /// are per-user: {login}/workflows-{login}. /// private string ForgejoLogin => (string?)HttpContext.Items["ForgejoLogin"] ?? throw new InvalidOperationException("ForgejoLogin not resolved by WorkflowSourceMiddleware"); /// Returns the Forgejo repo info for this tenant's workflow files. [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}", }); } /// /// Lists all files in the tenant's workflow repo (recursive, excluding .git). /// Returns relative paths and basic file info. /// [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() }); 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(); 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 }); } /// Returns the content of a specific file. [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 }); } /// /// 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). /// [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 }); } } /// Returns the git status of the tenant's workflow repo. [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(), untracked = Array.Empty() }); var branch = RunGit(cloneDir, "rev-parse", "--abbrev-ref", "HEAD")?.Trim() ?? ""; var statusOutput = RunGit(cloneDir, "status", "--porcelain") ?? ""; var changed = new List(); var untracked = new List(); 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 }); } /// /// Commits all changes and pushes to Forgejo. Optionally provides a commit message. /// After committing, call POST /api/workflows/sync to recompile. /// [HttpPost("commit")] [RequireScope("manage")] public async Task 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 }); } /// Pulls the latest changes from Forgejo. [HttpPost("pull")] [RequireScope("manage")] public async Task 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);