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.) that live in the tenant's per-tenant workflow repository. /// /// The repository is the single source of truth for a tenant's workflow content: /// the workflows module edits files here and POST /api/workflows/sync /// recompiles them (see ). Edits land in the /// SAME directory reads from, so the "edit in the /// workflows module" and "the engine runs it" paths are always coherent. /// /// Works in both storage modes: /// - Forgejo-backed (Forgejo:AdminToken set): files live in a private /// per-user Forgejo repo {login}/workflows-{login}, cloned locally. /// /commit and /pull sync to/from the remote. /// - Filesystem-only (no AdminToken): files live under /// {CopiesRoot}/{tenantId}/ with no remote. /commit and /// /pull are no-ops. /// [ApiController] [Route("api/workflow-files")] public class WorkflowFilesController : ControllerBase { public WorkflowFilesController( WorkflowSourceFactory sourceFactory, WorkflowRepoStore repoStore, WorkflowSyncService sync, RealtimeEventHub events, ILogger logger) { _sourceFactory = sourceFactory; _repoStore = repoStore; _sync = sync; _events = events; _logger = logger; } private readonly WorkflowSourceFactory _sourceFactory; private readonly WorkflowRepoStore _repoStore; private readonly WorkflowSyncService _sync; private readonly RealtimeEventHub _events; private readonly ILogger _logger; private string TenantId => (string?)HttpContext.Items["TenantId"] ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); /// /// 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). /// private string? MaybeForgejoLogin => HttpContext.Items.TryGetValue("ForgejoLogin", out var v) ? v as string : null; /// /// 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 /// reads from, so file edits made through these endpoints are what get compiled /// on sync. /// private string SourceDir => _sourceFactory.ResolveTenantSourceDir( TenantId, MaybeForgejoLogin, CurrentRepo); /// The tenant's current workflow repo name (set by the source middleware). private string CurrentRepo => (HttpContext.Items["WorkflowRepo"] as string) ?? (_sourceFactory.Forgejo?.WorkflowRepoName ?? "workflows"); /// 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 = MaybeForgejoLogin; if (string.IsNullOrWhiteSpace(login)) return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." }); 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); var exists = Directory.Exists(Path.Combine(cloneDir, ".git")); return Ok(new { repoName, repoFullName = fullName, cloneDir, cloned = exists, forgejoUrl = $"{_sourceFactory.Forgejo.ForgejoBaseUrl}/{fullName}", }); } /// /// Sets the tenant's workflow repository (basename, e.g. workflows or /// a user-selected repo) and re-syncs. Switching repos unloads workflows /// compiled from any previously-selected repo (they are no longer listed). /// [HttpPut("repo")] [RequireScope("manage")] public async Task SetRepo([FromBody] SetWorkflowRepoRequest? request, CancellationToken ct) { if (request == null || string.IsNullOrWhiteSpace(request.RepoName)) return BadRequest(new { error = "repoName is required" }); // 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}" }); } } 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, }); } /// /// Lists all files under a directory (recursive, excluding .git), plus the /// top-level subdirectories. Returns repo-relative paths. /// [HttpGet] [RequireScope("read")] public IActionResult ListFiles([FromQuery] string? path = null) { var sourceDir = SourceDir; if (!Directory.Exists(sourceDir)) return Ok(new { files = Array.Empty() }); var dir = string.IsNullOrWhiteSpace(path) ? sourceDir : Path.Combine(sourceDir, path.Replace('/', Path.DirectorySeparatorChar)); var fullDir = Path.GetFullPath(dir); if (!IsWithin(sourceDir, fullDir)) return BadRequest(new { error = "Invalid path" }); if (!Directory.Exists(fullDir)) return NotFound(new { error = $"Directory not found: {path}" }); var files = new List(); foreach (var full in Directory.EnumerateFiles(fullDir, "*", SearchOption.AllDirectories)) { var rel = Path.GetRelativePath(sourceDir, full).Replace(Path.DirectorySeparatorChar, '/'); if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase)) continue; var info = new FileInfo(full); files.Add(new { path = rel, name = Path.GetFileName(full), size = info.Length, modified = info.LastWriteTimeUtc }); } foreach (var full in Directory.EnumerateDirectories(fullDir)) { var name = Path.GetFileName(full); if (name == ".git") continue; var rel = Path.GetRelativePath(sourceDir, full).Replace(Path.DirectorySeparatorChar, '/'); files.Add(new { path = rel, name, isDir = true }); } return Ok(new { files }); } /// Returns the content of a specific file. [HttpGet("content")] [RequireScope("read")] public IActionResult GetContent([FromQuery] string path) { if (string.IsNullOrWhiteSpace(path)) return BadRequest(new { error = "path is required" }); var full = ResolveSafeFile(path, out var status); if (status.HasValue) 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 /// working tree (no commit/push — use POST /commit in Forgejo mode for that). /// [HttpPost("save")] [RequireScope("manage")] public IActionResult SaveFile([FromBody] SaveWorkflowFileRequest request) { if (string.IsNullOrWhiteSpace(request.Path)) return BadRequest(new { error = "path is required" }); var full = ResolveSafeFile(request.Path, out var status); if (status.HasValue) 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 tenant {TenantId} ({Login})", request.Path, TenantId, MaybeForgejoLogin ?? "-"); // 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); 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 }); } } /// /// 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. /// [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(); foreach (var raw in request.Paths) { if (string.IsNullOrWhiteSpace(raw)) continue; var full = Path.GetFullPath(Path.Combine(sourceDir, raw.Replace('/', Path.DirectorySeparatorChar))); if (full == fullSourceDir || !IsWithin(sourceDir, full)) 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 }); } /// Returns the git status of the tenant's workflow repo (Forgejo-backed mode only). [HttpGet("status")] [RequireScope("read")] public IActionResult GetStatus() { if (_sourceFactory.Forgejo == null) return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." }); var login = MaybeForgejoLogin; if (string.IsNullOrWhiteSpace(login)) return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." }); var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(TenantId, login, CurrentRepo); 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 and pushes all changes to Forgejo. A no-op (returns success) in /// filesystem-only mode. 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 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." }); 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( TenantId, login, message, CurrentRepo, HttpContext.RequestAborted); _logger.LogInformation("Commit/push for login {Login}: {Success} ({Output})", login, success, output?.Trim() ?? ""); return Ok(new { success, output }); } /// Pulls the latest changes from Forgejo (no-op in filesystem-only mode). [HttpPost("pull")] [RequireScope("manage")] public async Task Pull() { if (_sourceFactory.Forgejo == null) 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." }); var ok = await _sourceFactory.Forgejo.PullAsync(TenantId, login, CurrentRepo, HttpContext.RequestAborted); return Ok(new { success = ok }); } /// Resolves a repo-relative path to an absolute path inside the tenant source dir. private string ResolveSafeFile(string path, out int? status) { var sourceDir = SourceDir; var full = Path.GetFullPath(Path.Combine(sourceDir, path.Replace('/', Path.DirectorySeparatorChar))); if (!IsWithin(sourceDir, full)) { status = 400; return full; } status = null; return full; } /// /// True when 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. /// 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); } 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 SetWorkflowRepoRequest(string? RepoName); public sealed record SaveWorkflowFileRequest(string Path, string? Content); public sealed record CommitWorkflowRequest(string? Message); public sealed record DeleteWorkflowFileRequest(string[] Paths);