w4c-workflows-api/Services/ForgejoWorkflowRepoService.cs
Vitali sharp8n 3bad99fad3 security fix
2026-09-12 21:45:33 +03:00

526 lines
23 KiB
C#

using System.Diagnostics;
using Microsoft.AspNetCore.Hosting;
namespace w4c_workflows.Services;
/// <summary>
/// Manages per-tenant private Forgejo repositories for workflow source files.
/// Each tenant gets a repo named <c>workflows-{tenantId}</c> under the configured
/// Forgejo owner. The repo contains the tenant's workflow YAML definitions and
/// their sibling code files (shell scripts, JS, Python, etc.).
///
/// On first access the repo is created via the Forgejo admin API, cloned locally,
/// and seeded with templates from <c>WorkflowSource:SharedDir</c>. Subsequent calls
/// pull remote changes before the sync service reads the files.
///
/// The local clone lives at <c>{WorkflowSource:CopiesRoot}/{tenantId}/</c> — the
/// same directory layout the legacy <see cref="PerTenantWorkflowSource"/> used, so
/// the existing <see cref="WorkflowSyncService"/> and worker runtime need no path
/// changes when the backing store switches from plain filesystem to git.
/// </summary>
public sealed class ForgejoWorkflowRepoService
{
/// <summary>
/// Hard ceiling for any single git invocation. Without it, `git pull`/`git clone`
/// against an unreachable remote (e.g. a stale Forgejo base URL) hangs forever,
/// blocking the request that triggered it. See <see cref="RunGitAsync"/>.
/// </summary>
private static readonly TimeSpan GitOperationTimeout = TimeSpan.FromSeconds(20);
private readonly string _forgejoBase;
private readonly string? _forgejoToken;
private readonly string? _forgejoAdminToken;
private readonly string _forgejoOwner;
private readonly string _copiesRoot;
private readonly ILogger<ForgejoWorkflowRepoService> _logger;
public ForgejoWorkflowRepoService(IConfiguration config, IWebHostEnvironment env, ILogger<ForgejoWorkflowRepoService> logger)
{
_logger = logger;
_forgejoBase = (config["Forgejo:BaseUrl"] ?? "https://forgejo.wiz4chat.com").TrimEnd('/');
_forgejoToken = config["Forgejo:AccessToken"]?.Trim();
_forgejoAdminToken = config["Forgejo:AdminToken"]?.Trim();
// Prefer WorkflowRepoOwner, but treat an explicit empty string as
// "not configured" and fall back to Forgejo:Owner. A plain `??` is not
// enough: appsettings ships `"WorkflowRepoOwner": ""`, which is non-null
// and would win over Forgejo:Owner, silently disabling Forgejo-backed
// workflow mode (IsConfigured reads owner as empty).
var workflowRepoOwner = config["Forgejo:WorkflowRepoOwner"]?.Trim();
_forgejoOwner = !string.IsNullOrWhiteSpace(workflowRepoOwner)
? workflowRepoOwner
: config["Forgejo:Owner"]?.Trim() ?? string.Empty;
// Resolve CopiesRoot against the content root (not the process CWD) so a
// relative value like "../source-copies" lands on the same wizard root
// regardless of the working directory the service was launched from.
_copiesRoot = WorkflowSourceFactory.ResolveCopiesRoot(
config["WorkflowSource:CopiesRoot"]
?? throw new InvalidOperationException(
"WorkflowSource:CopiesRoot is not configured. " +
"Set it to a writable directory path (e.g. \"/data/workflow-tenants\")."),
env);
// The workflow repo name (basename). Default "workflows" → {login}/workflows,
// so a tenant's workflows live in a repo whose name contains "workflow".
// A per-tenant selection overrides this at runtime.
var repoName = config["WorkflowSource:WorkflowRepoName"]?.Trim();
WorkflowRepoName = string.IsNullOrWhiteSpace(repoName) ? "workflows" : repoName;
}
/// <summary>The Forgejo base URL (for building repo links).</summary>
public string ForgejoBaseUrl => _forgejoBase;
/// <summary>
/// The token used for git transport (clone/pull/push) against the per-user
/// private workflow repos. The repos are created and administered with the
/// ADMIN token, so that is the credential that actually works against them;
/// the plain AccessToken has no read/write grant on a user's private repo and
/// Forgejo reports it as "Repository not found". Fall back to the access
/// token when no admin token is configured.
/// </summary>
private string GitAuthToken =>
!string.IsNullOrWhiteSpace(_forgejoAdminToken) ? _forgejoAdminToken : _forgejoToken;
/// <summary>True when Forgejo admin provisioning is configured.</summary>
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(_forgejoAdminToken) &&
!string.IsNullOrWhiteSpace(_forgejoOwner);
/// <summary>The repo full name on Forgejo (owner/repo-name).</summary>
/// <summary>
/// The repository name (basename) used for a tenant's workflow repo. Default
/// is <c>workflows</c> (the repo full name <c>{login}/workflows</c>), so a
/// tenant's workflows live in a repo whose name contains "workflow". A tenant
/// may select a different repo (e.g. <c>wiz4apps</c>) to share the directory
/// with the main-api Source Code explorer; the selection is persisted per
/// tenant and overrides this default.
/// </summary>
public string WorkflowRepoName { get; }
/// <summary>
/// Consolidated repo-name resolver. Both the workflows-api engine and the
/// webapi source-code explorer operate on a local clone whose directory name is
/// the slug of the repo full name (<c>{owner}_{repo}</c>). Deriving full name AND
/// slug from a single repo name is what keeps the two services pointing at the
/// same directory.
/// </summary>
public static (string RepoFullName, string RepoSlug) ResolveWorkflowRepo(string login, string repoName)
{
var owner = Sanitize(login.ToLowerInvariant());
var repo = Sanitize(repoName);
return ($"{owner}/{repo}", $"{owner}_{repo}");
}
/// <summary>
/// Resolves the Forgejo repo full name for a user given the configured/selected
/// workflow repo name: <c>{login}/{repoName}</c>.
/// </summary>
public string RepoFullNameForLogin(string login, string? repoName = null) =>
ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoFullName;
/// <summary>
/// The source-copies owner-repo slug for a user's workflow repo, matching the
/// webapi SourceCodeCopyService layout (<c>{owner}_{repo}</c>, e.g.
/// <c>test_workflows</c>). Sharing this exact directory with the webapi
/// source-code explorer is what keeps workflow edits visible in the source-code
/// view without a manual fetch/pull.
/// </summary>
public string OwnerRepoSlug(string login, string? repoName = null) =>
ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoSlug;
/// <summary>
/// Ensures the user's Forgejo repo exists. Creates it via the admin API
/// if missing (idempotent). Returns the repo full name (owner/repo).
/// Throws on hard failures.
/// </summary>
public async Task<string> EnsureRepoAsync(string login, string? repoName = null, CancellationToken ct = default)
{
if (!IsConfigured)
throw new InvalidOperationException(
"Forgejo workflow repo provisioning is not configured. " +
"Set Forgejo:AdminToken and Forgejo:Owner (or Forgejo:WorkflowRepoOwner).");
var owner = login.ToLowerInvariant();
var name = Sanitize(repoName ?? WorkflowRepoName);
var fullName = $"{owner}/{name}";
try
{
// Check if repo already exists.
using var exists = await SendAdminAsync(HttpMethod.Get,
$"/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}", null, ct);
if (exists.IsSuccessStatusCode)
{
_logger.LogDebug("Forgejo workflow repo {FullName} already exists", fullName);
return fullName;
}
// Create the repo.
using var create = await SendAdminAsync(HttpMethod.Post,
$"/admin/users/{Uri.EscapeDataString(owner)}/repos",
new
{
name,
@private = true,
auto_init = true,
description = $"Workflow definitions for user {login}",
}, ct);
if (!create.IsSuccessStatusCode)
{
var body = await create.Content.ReadAsStringAsync(ct);
_logger.LogWarning("Forgejo workflow repo create {FullName} -> {Status}: {Body}",
fullName, (int)create.StatusCode, body);
throw new InvalidOperationException(
$"Could not create Forgejo workflow repo {fullName}: {(int)create.StatusCode}");
}
_logger.LogInformation("Created Forgejo workflow repo {FullName}", fullName);
return fullName;
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Forgejo workflow repo ensure failed for {FullName}", fullName);
throw new InvalidOperationException(
$"Could not create Forgejo workflow repo {fullName}: {ex.Message}");
}
}
/// <summary>
/// Ensures a local clone of the user's workflow repo exists. If the repo
/// doesn't exist on Forgejo yet, it is created. If the clone directory is
/// missing, the repo is cloned. Returns the absolute path to the local clone.
/// </summary>
public async Task<string> EnsureCloneAsync(string tenantId, string login, string? repoName = null, CancellationToken ct = default)
{
var tenantDir = TenantCloneDir(tenantId, login, repoName);
if (Directory.Exists(Path.Combine(tenantDir, ".git")))
{
// The clone already exists, but its remote `origin` may point at an old
// Forgejo base (e.g. after the server location changed from
// forgejo.wiz4chat.com to localhost). Pulling from a dead origin hangs
// indefinitely (no timeout), blocking every request that runs the
// post-auth middleware. Reconcile origin to the configured base so
// subsequent pulls target the live server. Best-effort: never fail the
// request over a remote rewrite.
await ReconcileOriginAsync(tenantDir, login, repoName, ct);
_logger.LogDebug("Local clone for login {Login} already exists at {Dir}", login, tenantDir);
return tenantDir;
}
// Ensure the Forgejo repo exists. The selected repo name must be honored
// here; otherwise switching to a non-default repo would clone the default
// repo ("workflows") into the directory named after the selected repo.
var fullName = await EnsureRepoAsync(login, repoName, ct);
var cloneUrl = $"{_forgejoBase}/{fullName}.git";
Directory.CreateDirectory(tenantDir);
// Clone the repo.
var psi = new ProcessStartInfo("git")
{
WorkingDirectory = tenantDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("clone");
// The per-user workflow repos are created and managed via the Forgejo
// ADMIN token, and each is private (owned by the user's own account).
// The plain AccessToken has no grant on those private repos (Forgejo
// returns 404), so cloning with it makes git fail with "Repository not
// found". Use the admin token (fallback to the access token) so the
// clone — and the later pull/push — succeed against the private repo.
if (!string.IsNullOrWhiteSpace(GitAuthToken))
{
psi.ArgumentList.Add("-c");
psi.ArgumentList.Add($"http.extraHeader=Authorization: token {GitAuthToken}");
}
psi.ArgumentList.Add("--");
psi.ArgumentList.Add(cloneUrl);
psi.ArgumentList.Add(".");
using var process = Process.Start(psi);
if (process == null)
throw new InvalidOperationException("git clone failed to start");
using var cloneTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
cloneTimeout.CancelAfter(GitOperationTimeout);
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(cloneTimeout.Token);
}
catch (OperationCanceledException)
{
TryKill(process);
_logger.LogWarning("Clone {FullName} timed out after {Timeout}s", fullName, GitOperationTimeout.TotalSeconds);
throw new InvalidOperationException(
$"git clone of {fullName} timed out after {GitOperationTimeout.TotalSeconds}s.");
}
var stdout = await stdoutTask;
var stderr = await stderrTask;
if (process.ExitCode != 0)
{
_logger.LogWarning("Clone {FullName} failed ({Code}): {Err}",
fullName, process.ExitCode, stderr.Trim());
throw new InvalidOperationException(
string.IsNullOrWhiteSpace(stderr) ? "git clone failed" : stderr.Trim());
}
_logger.LogInformation("Cloned workflow repo {FullName} into {Dir}", fullName, tenantDir);
// Clean up any baked-in extra header from clone config.
UnsetLocalConfig(tenantDir, "http.extraheader");
// Pin the remote to the configured Forgejo base (git infers origin from
// the clone URL, but an explicit set-url guarantees it matches the current
// configuration even if the base URL changed between clones).
await RunGitAsync(tenantDir, ct, "remote", "set-url", "origin", cloneUrl);
// NOTE: we deliberately do NOT seed shared example templates into a fresh
// workflow repo. Workflows are strictly per-tenant: a tenant starts with
// exactly the content of their own repo (empty for a freshly-created repo)
// and builds up their own definitions. Seeding shared examples made every
// tenant see the same "predefined" workflows and broke tenant isolation.
return tenantDir;
}
/// <summary>
/// Pulls the latest changes from the remote. Returns true on success.
/// </summary>
public async Task<bool> PullAsync(string tenantId, string login, string? repoName = null, CancellationToken ct = default)
{
var tenantDir = TenantCloneDir(tenantId, login, repoName);
if (!Directory.Exists(Path.Combine(tenantDir, ".git")))
{
_logger.LogWarning("No local clone for login {Login}, cannot pull", login);
return false;
}
var token = GitAuthToken;
var output = !string.IsNullOrWhiteSpace(token)
? await RunGitAsync(tenantDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "pull", "--ff-only")
: await RunGitAsync(tenantDir, ct, "pull", "--ff-only");
if (output == null)
{
_logger.LogWarning("Pull failed for login {Login}", login);
return false;
}
var ok = !output.Contains("fatal:", StringComparison.OrdinalIgnoreCase) &&
!output.Contains("Could not resolve host", StringComparison.OrdinalIgnoreCase) &&
!output.Contains("Permission denied", StringComparison.OrdinalIgnoreCase);
_logger.LogInformation("Pull for login {Login}: {Ok} ({Out})",
login, ok, output.Trim());
return ok;
}
/// <summary>
/// Commits and pushes all changes in the tenant's clone. Used after file
/// edits via the source code API to persist changes to Forgejo.
/// </summary>
public async Task<(bool Success, string Output)> CommitAndPushAsync(
string tenantId, string login, string message, string? repoName = null, CancellationToken ct = default)
{
var tenantDir = TenantCloneDir(tenantId, login, repoName);
if (!Directory.Exists(Path.Combine(tenantDir, ".git")))
return (false, "No local clone");
await RunGitAsync(tenantDir, ct, "add", "-A");
var commitOutput = await RunGitAsync(tenantDir, ct, "commit", "-m", message);
if (commitOutput == null || commitOutput.Contains("nothing to commit", StringComparison.OrdinalIgnoreCase))
return (true, "Nothing to commit");
var token = GitAuthToken;
var pushOutput = !string.IsNullOrWhiteSpace(token)
? await RunGitAsync(tenantDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "push")
: await RunGitAsync(tenantDir, ct, "push");
var ok = pushOutput != null &&
!pushOutput.Contains("fatal:", StringComparison.OrdinalIgnoreCase) &&
!pushOutput.Contains("Permission denied", StringComparison.OrdinalIgnoreCase);
return (ok, ok ? pushOutput?.Trim() ?? "Pushed" : pushOutput?.Trim() ?? "Push failed");
}
/// <summary>
/// The absolute path to a tenant's local clone directory — shared with the
/// webapi source-code explorer. Layout:
/// <c>{CopiesRoot}/{tenantId}/{ownerRepo}</c> where <c>ownerRepo</c> is
/// <see cref="OwnerRepoSlug"/> (e.g. <c>source-copies/10/test_workflows-test</c>).
/// Both the w4c-workflows-api engine and the webapi SourceCodeController operate
/// on this one working copy, so workflow edits and the source-code view stay
/// consistent without a manual git pull.
/// </summary>
public string TenantCloneDir(string tenantId, string login, string? repoName = null)
=> Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId), OwnerRepoSlug(login, repoName)));
/// <summary>
/// Runs git with a hard wall-clock timeout. Default git has no connect/transfer
/// timeout, so a dead or unreachable remote (e.g. a stale Forgejo base URL after
/// the server moved) would otherwise block the calling request forever. On timeout
/// the whole process tree is killed and null is returned instead of hanging.
/// </summary>
private async Task<string?> RunGitAsync(string workDir, CancellationToken ct, params string[] args)
{
try
{
var psi = new ProcessStartInfo("git")
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
using var process = Process.Start(psi);
if (process == null) return null;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(GitOperationTimeout);
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
// Timed out (or the request was aborted) — kill the process tree so a
// hung git transport can't leak or keep a subsequent request blocked.
TryKill(process);
return null;
}
var stdout = await stdoutTask;
var stderr = await stderrTask;
return process.ExitCode == 0 ? stdout : stderr;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "git invocation failed in {Dir}", workDir);
return null;
}
}
/// <summary>
/// Ensures the clone's remote `origin` points at the currently-configured Forgejo
/// base. Used when a clone already exists but may have been created against an old
/// Forgejo location (e.g. forgejo.wiz4chat.com → localhost). A stale origin makes
/// `git pull` hang against a dead host. Best-effort and non-blocking on failure.
/// </summary>
private async Task ReconcileOriginAsync(string tenantDir, string login, string? repoName, CancellationToken ct)
{
var fullName = RepoFullNameForLogin(login, repoName);
var desired = $"{_forgejoBase}/{fullName}.git";
try
{
var current = await RunGitAsync(tenantDir, ct, "remote", "get-url", "origin");
if (string.IsNullOrWhiteSpace(current))
return;
current = current.Trim();
if (current.Equals(desired, StringComparison.OrdinalIgnoreCase))
return;
_logger.LogInformation(
"Reconciling workflow repo origin {Dir}: {Current} -> {Desired}",
tenantDir, current, desired);
await RunGitAsync(tenantDir, ct, "remote", "set-url", "origin", desired);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not reconcile origin for {Dir}", tenantDir);
}
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
}
catch
{
// Best-effort cleanup.
}
}
private async Task<HttpResponseMessage> SendAdminAsync(
HttpMethod method, string path, object? body, CancellationToken ct)
{
using var request = new HttpRequestMessage(method, $"{_forgejoBase}/api/v1{path}");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", _forgejoAdminToken);
if (body != null)
{
request.Content = new StringContent(
System.Text.Json.JsonSerializer.Serialize(body, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
}),
System.Text.Encoding.UTF8,
"application/json");
}
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
return await client.SendAsync(request, ct);
}
private static void UnsetLocalConfig(string repoRoot, string key)
{
try
{
var psi = new ProcessStartInfo("git")
{
WorkingDirectory = repoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("config");
psi.ArgumentList.Add("--local");
psi.ArgumentList.Add("--unset");
psi.ArgumentList.Add(key);
using var p = Process.Start(psi);
p?.WaitForExit(3000);
}
catch
{
// Best-effort cleanup.
}
}
private static string Sanitize(string id)
{
if (string.IsNullOrWhiteSpace(id))
return "_";
var sb = new System.Text.StringBuilder(id.Length);
foreach (var ch in id)
sb.Append(char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_');
var result = sb.ToString();
return result.Length > 120 ? result[..120] : result;
}
}