diff --git a/.htaccess b/.htaccess index e087bb5..d660b99 100644 --- a/.htaccess +++ b/.htaccess @@ -8,6 +8,9 @@ RewriteRule ^api/license/v1(?:/(.*))?$ public/api/license/v1/index.php [L,QSA] RewriteRule ^api/watchdog/v1(?:/(.*))?$ public/api/watchdog/v1/index.php [L,QSA] RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA] + RewriteRule ^api/tokens/v1/provision public/api/tokens/v1/provision.php [L,QSA] + RewriteRule ^api/bugtracker/v1/report public/api/bugtracker/v1/report.php [L,QSA] + RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA] # Fallback for static assets in /api/ RewriteRule ^api/(.*)$ public/api/$1 [L,QSA] diff --git a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets new file mode 100644 index 0000000..2f95048 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets @@ -0,0 +1,49 @@ + + + + + $(IntermediateOutputPath)BuildInfo.g.cs + $([System.DateTime]::UtcNow.ToString("o")) + $(Version) + 1.0.0 + prod + + + + + + + + + + + UNKNOWN_COMMIT + UNKNOWN + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj index 044fad6..31669a4 100644 --- a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj +++ b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj @@ -16,6 +16,8 @@ + + diff --git a/client-dotnet/Deploymentcenter.Client/Models/BuildInfo.cs b/client-dotnet/Deploymentcenter.Client/Models/BuildInfo.cs new file mode 100644 index 0000000..a1a778f --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/Models/BuildInfo.cs @@ -0,0 +1,18 @@ +using System; + +namespace Deploymentcenter.Client.Models +{ + /// + /// Runtime accessibility for build metadata embedded at compile-time. + /// + public static class BuildInfo + { + public static string Version { get; set; } = "1.0.0"; + public static string GitCommit { get; set; } = "HEAD"; + public static string GitCommitShort { get; set; } = "HEAD"; + public static string BuildDateUtc { get; set; } = DateTime.UtcNow.ToString("o"); + public static string Channel { get; set; } = "prod"; + + public static string Summary => $"v{Version} ({GitCommitShort}) built on {BuildDateUtc} [{Channel}]"; + } +} diff --git a/client-dotnet/Deploymentcenter.Client/Models/PackageManifest.cs b/client-dotnet/Deploymentcenter.Client/Models/PackageManifest.cs new file mode 100644 index 0000000..8206326 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/Models/PackageManifest.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Deploymentcenter.Client.Models +{ + /// + /// Model for per-package manifest.json stored inside package.tar.gz + /// and beside it on the LEMP release server. + /// + public class PackageManifest + { + [JsonPropertyName("projectId")] + public string ProjectId { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("channel")] + public string Channel { get; set; } = "prod"; + + [JsonPropertyName("buildDate")] + public string BuildDate { get; set; } = string.Empty; + + [JsonPropertyName("gitCommit")] + public string GitCommit { get; set; } = string.Empty; + + [JsonPropertyName("gitCommitShort")] + public string GitCommitShort { get; set; } = string.Empty; + + [JsonPropertyName("changelog")] + public string Changelog { get; set; } = string.Empty; + + [JsonPropertyName("files")] + public List Files { get; set; } = new List(); + } + + /// + /// File entry item inside manifest.json for integrity checking and repair. + /// + public class PackageFileEntry + { + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("sha256")] + public string Sha256 { get; set; } = string.Empty; + + [JsonPropertyName("sizeBytes")] + public long SizeBytes { get; set; } + } +} diff --git a/client-dotnet/Deploymentcenter.Client/Models/ReleaseManifest.cs b/client-dotnet/Deploymentcenter.Client/Models/ReleaseManifest.cs new file mode 100644 index 0000000..aa52a4d --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/Models/ReleaseManifest.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Deploymentcenter.Client.Models +{ + /// + /// Model for channel-level latest.json served by LEMP / Nginx or Deploymentcenter API. + /// + public class ReleaseManifest + { + [JsonPropertyName("projectId")] + public string ProjectId { get; set; } = string.Empty; + + [JsonPropertyName("channel")] + public string Channel { get; set; } = "prod"; + + [JsonPropertyName("latest")] + public VersionInfo? Latest { get; set; } + + [JsonPropertyName("versions")] + public List Versions { get; set; } = new List(); + } + + /// + /// Individual release version details. + /// + public class VersionInfo + { + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("buildDate")] + public string BuildDate { get; set; } = string.Empty; + + [JsonPropertyName("gitCommit")] + public string GitCommit { get; set; } = string.Empty; + + [JsonPropertyName("gitCommitShort")] + public string GitCommitShort { get; set; } = string.Empty; + + [JsonPropertyName("packageUrl")] + public string PackageUrl { get; set; } = string.Empty; + + [JsonPropertyName("sha256")] + public string Sha256 { get; set; } = string.Empty; + + [JsonPropertyName("sizeBytes")] + public long SizeBytes { get; set; } + + [JsonPropertyName("changelog")] + public string Changelog { get; set; } = string.Empty; + + [JsonPropertyName("isCritical")] + public bool IsCritical { get; set; } + } +} diff --git a/client-dotnet/Deploymentcenter.Client/UpdateClient.cs b/client-dotnet/Deploymentcenter.Client/UpdateClient.cs new file mode 100644 index 0000000..5d54fd8 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/UpdateClient.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Deploymentcenter.Client.Models; + +namespace Deploymentcenter.Client +{ + public class UpdateCheckResult + { + public bool UpdateAvailable { get; set; } + public bool IsCritical { get; set; } + public VersionInfo? LatestRelease { get; set; } + public ReleaseManifest? FullManifest { get; set; } + public string Message { get; set; } = string.Empty; + public Exception? Error { get; set; } + } + + public class IntegrityCheckResult + { + public bool IsValid { get; set; } = true; + public List MissingFiles { get; } = new List(); + public List CorruptedFiles { get; } = new List(); + public int TotalCheckedFiles { get; set; } + } + + public class UpdateClient + { + private static readonly HttpClient SharedHttpClient = new HttpClient(); + private readonly HttpClient _httpClient; + + public UpdateClient(HttpClient? httpClient = null) + { + _httpClient = httpClient ?? SharedHttpClient; + } + + /// + /// Checks for update availability against LEMP static latest.json or Deploymentcenter API. + /// + public async Task CheckForUpdateAsync( + string baseUrl, + string projectId, + string currentVersion, + string channel = "prod", + CancellationToken cancellationToken = default) + { + var result = new UpdateCheckResult(); + try + { + string cleanBaseUrl = baseUrl.TrimEnd('/'); + + // Primary check: LEMP static channel latest.json + // Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json + string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}/latest.json"; + + HttpResponseMessage response; + try + { + response = await _httpClient.GetAsync(staticUrl, cancellationToken).ConfigureAwait(false); + } + catch + { + // Fallback check: Deploymentcenter API endpoint + // Path pattern: https://domain/api/updateservice/v1/check?product={projectId}&version={currentVersion} + string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check?product={Uri.EscapeDataString(projectId)}&version={Uri.EscapeDataString(currentVersion)}"; + response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false); + } + + if (!response.IsSuccessStatusCode) + { + result.Message = $"HTTP Error {response.StatusCode} during update check"; + return result; + } + + string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Handle static latest.json format + if (root.TryGetProperty("latest", out var latestProp) && latestProp.ValueKind == JsonValueKind.Object) + { + var manifest = JsonSerializer.Deserialize(json); + if (manifest?.Latest != null) + { + result.FullManifest = manifest; + result.LatestRelease = manifest.Latest; + + if (IsVersionNewer(currentVersion, manifest.Latest.Version)) + { + result.UpdateAvailable = true; + result.IsCritical = manifest.Latest.IsCritical; + result.Message = $"New release v{manifest.Latest.Version} available."; + } + else + { + result.Message = "Application is up to date."; + } + } + } + // Handle API response format + else if (root.TryGetProperty("update_available", out var availProp)) + { + bool available = availProp.GetBoolean(); + result.UpdateAvailable = available; + if (root.TryGetProperty("latest_release", out var relProp)) + { + var info = JsonSerializer.Deserialize(relProp.GetRawText()); + result.LatestRelease = info; + result.IsCritical = info?.IsCritical ?? false; + } + result.Message = available ? "Update available." : "Application is up to date."; + } + } + catch (Exception ex) + { + result.Error = ex; + result.Message = $"Update check failed: {ex.Message}"; + } + + return result; + } + + /// + /// Validates local application integrity against manifest.json. + /// + public static IntegrityCheckResult VerifyIntegrity(string localAppDir, PackageManifest manifest) + { + var result = new IntegrityCheckResult(); + if (manifest == null || manifest.Files == null || manifest.Files.Count == 0) + { + return result; + } + + foreach (var entry in manifest.Files) + { + result.TotalCheckedFiles++; + string fullPath = Path.Combine(localAppDir, entry.Path.Replace('/', Path.DirectorySeparatorChar)); + + if (!File.Exists(fullPath)) + { + result.IsValid = false; + result.MissingFiles.Add(entry.Path); + continue; + } + + if (!string.IsNullOrEmpty(entry.Sha256)) + { + string computedHash = ComputeSha256(fullPath); + if (!string.Equals(computedHash, entry.Sha256, StringComparison.OrdinalIgnoreCase)) + { + result.IsValid = false; + result.CorruptedFiles.Add(entry.Path); + } + } + } + + return result; + } + + /// + /// Launches UpdateAgent process with appropriate parameters and optionally exits current application. + /// + public static bool LaunchUpdateAgent( + string agentPath, + string projectId, + string channel = "prod", + string action = "update", + string version = "latest", + string? targetDir = null, + bool exitCurrentApp = true) + { + if (!File.Exists(agentPath)) + { + return false; + } + + targetDir ??= AppDomain.CurrentDomain.BaseDirectory; + + var args = new StringBuilder(); + args.Append($"--project \"{projectId}\" "); + args.Append($"--channel \"{channel}\" "); + args.Append($"--action \"{action}\" "); + args.Append($"--version \"{version}\" "); + args.Append($"--target-dir \"{targetDir}\""); + + var startInfo = new ProcessStartInfo + { + FileName = agentPath, + Arguments = args.ToString(), + UseShellExecute = true + }; + + Process.Start(startInfo); + + if (exitCurrentApp) + { + Environment.Exit(0); + } + + return true; + } + + public static string ComputeSha256(string filePath) + { + using var sha256 = SHA256.Create(); + using var stream = File.OpenRead(filePath); + byte[] hash = sha256.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + public static bool IsVersionNewer(string currentVer, string remoteVer) + { + if (string.IsNullOrEmpty(remoteVer)) return false; + if (string.IsNullOrEmpty(currentVer)) return true; + + string CleanVer(string v) + { + int dash = v.IndexOf('-'); + return dash > 0 ? v.Substring(0, dash) : v; + } + + if (Version.TryParse(CleanVer(currentVer), out var cVer) && + Version.TryParse(CleanVer(remoteVer), out var rVer)) + { + return rVer > cVer; + } + + return string.Compare(remoteVer, currentVer, StringComparison.OrdinalIgnoreCase) > 0; + } + } +} diff --git a/client-dotnet/Deploymentcenter.Packager/Deploymentcenter.Packager.csproj b/client-dotnet/Deploymentcenter.Packager/Deploymentcenter.Packager.csproj new file mode 100644 index 0000000..1fb5fbf --- /dev/null +++ b/client-dotnet/Deploymentcenter.Packager/Deploymentcenter.Packager.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + Deploymentcenter.Packager + pack-and-deploy + + + + + + + + + + + + diff --git a/client-dotnet/Deploymentcenter.Packager/Program.cs b/client-dotnet/Deploymentcenter.Packager/Program.cs new file mode 100644 index 0000000..5b9c20f --- /dev/null +++ b/client-dotnet/Deploymentcenter.Packager/Program.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Deploymentcenter.Client.Models; +using FluentFTP; + +namespace Deploymentcenter.Packager +{ + public class PackagerConfig + { + public string FtpHost { get; set; } = "www531.your-server.de"; + public int FtpPort { get; set; } = 21; + public string FtpUser { get; set; } = "bergisnu_4"; + public string FtpPass { get; set; } = "o2#M*NN^5EsT"; + public string FtpRemoteBaseDir { get; set; } = "/public_html/releases"; + public string ApiBaseUrl { get; set; } = "https://dc.mhdf.de"; + public List ExcludePatterns { get; set; } = new List + { + "*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/*" + }; + } + + class Program + { + static async Task Main(string[] args) + { + Console.WriteLine("================================================="); + Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v1.0 "); + Console.WriteLine("================================================="); + + string project = GetArg(args, "--project", "-p") ?? "myapp"; + string version = GetArg(args, "--version", "-v") ?? "1.0.0"; + string channel = GetArg(args, "--channel", "-c") ?? "prod"; + string publishDir = GetArg(args, "--publish-dir", "-d") ?? Directory.GetCurrentDirectory(); + string changelog = GetArg(args, "--changelog") ?? $"Release v{version}"; + bool isCritical = HasFlag(args, "--critical"); + string configFile = GetArg(args, "--config") ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "packager.config.json"); + + PackagerConfig config = LoadConfig(configFile); + + // Override config with explicit CLI args if provided + string ftpHost = GetArg(args, "--ftp-host") ?? config.FtpHost; + string ftpUser = GetArg(args, "--ftp-user") ?? config.FtpUser; + string ftpPass = GetArg(args, "--ftp-pass") ?? config.FtpPass; + string remoteBase = GetArg(args, "--remote-dir") ?? config.FtpRemoteBaseDir; + + publishDir = Path.GetFullPath(publishDir); + if (!Directory.Exists(publishDir)) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"[ERROR] Publish directory does not exist: {publishDir}"); + Console.ResetColor(); + return 1; + } + + Console.WriteLine($"[INFO] Packaging Project : {project}"); + Console.WriteLine($"[INFO] Version : {version}"); + Console.WriteLine($"[INFO] Channel : {channel}"); + Console.WriteLine($"[INFO] Publish Directory : {publishDir}"); + + // 1. Gather files and filter exclusions + var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories); + var filteredFiles = new List(); + + foreach (var file in allFiles) + { + string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); + if (IsExcluded(relPath, config.ExcludePatterns)) + { + Console.WriteLine($" [EXCLUDED] {relPath}"); + continue; + } + filteredFiles.Add(file); + } + + Console.WriteLine($"[INFO] Total files selected for package: {filteredFiles.Count}"); + + // 2. Prepare staging directory + string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outputTempDir); + + string packageTarGzPath = Path.Combine(outputTempDir, "package.tar.gz"); + string manifestJsonPath = Path.Combine(outputTempDir, "manifest.json"); + string sha256FilePath = Path.Combine(outputTempDir, "package.tar.gz.sha256"); + + string gitCommit = GetGitCommitLong(); + string gitCommitShort = GetGitCommitShort(); + string buildDateUtc = DateTime.UtcNow.ToString("o"); + + // Build Manifest + var packageManifest = new PackageManifest + { + ProjectId = project, + Version = version, + Channel = channel, + BuildDate = buildDateUtc, + GitCommit = gitCommit, + GitCommitShort = gitCommitShort, + Changelog = changelog, + Files = new List() + }; + + foreach (var file in filteredFiles) + { + string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); + long size = new FileInfo(file).Length; + string hash = ComputeSha256(file); + packageManifest.Files.Add(new PackageFileEntry + { + Path = relPath, + SizeBytes = size, + Sha256 = hash + }); + } + + // Write manifest.json + string manifestJson = JsonSerializer.Serialize(packageManifest, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(manifestJsonPath, manifestJson); + + // 3. Create package.tar.gz + Console.WriteLine("[INFO] Creating package.tar.gz archive..."); + string archiveStaging = Path.Combine(outputTempDir, "archive_root"); + Directory.CreateDirectory(archiveStaging); + + foreach (var file in filteredFiles) + { + string relPath = Path.GetRelativePath(publishDir, file); + string targetFile = Path.Combine(archiveStaging, relPath); + Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!); + File.Copy(file, targetFile, true); + } + + // Also place manifest.json inside archive root + File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true); + + // Compress to tar.gz using System.Formats.Tar + GZipStream + using (var fs = File.Create(packageTarGzPath)) + using (var gz = new GZipStream(fs, CompressionLevel.Optimal)) + { + TarFile.CreateFromDirectory(archiveStaging, gz, includeBaseDirectory: false); + } + + long packageSizeBytes = new FileInfo(packageTarGzPath).Length; + string packageSha256 = ComputeSha256(packageTarGzPath); + await File.WriteAllTextAsync(sha256FilePath, packageSha256); + + Console.WriteLine($"[SUCCESS] Package created successfully! ({packageSizeBytes} bytes)"); + Console.WriteLine($"[INFO] Package SHA256: {packageSha256}"); + + // 4. FTP Upload to LEMP Release Server + string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}"; + string remoteVersionPath = $"{remoteChannelPath}/{version}"; + + Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})..."); + + try + { + using var ftp = new AsyncFtpClient(ftpHost, ftpUser, ftpPass, config.FtpPort); + await ftp.Connect(); + + await ftp.CreateDirectory(remoteVersionPath); + + // Upload package.tar.gz, package.tar.gz.sha256, and manifest.json + await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite); + await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite); + await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite); + + Console.WriteLine("[SUCCESS] Files uploaded to version directory!"); + + // 5. Update remote channel latest.json + string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json"; + ReleaseManifest channelManifest = new ReleaseManifest + { + ProjectId = project, + Channel = channel, + Versions = new List() + }; + + // Read existing latest.json if present on FTP + if (await ftp.FileExists(remoteLatestJsonPath)) + { + string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json"); + var status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite); + if (status == FtpStatus.Success && File.Exists(tempLatestLocal)) + { + try + { + string existingJson = await File.ReadAllTextAsync(tempLatestLocal); + var existingManifest = JsonSerializer.Deserialize(existingJson); + if (existingManifest != null && existingManifest.Versions != null) + { + channelManifest.Versions = existingManifest.Versions; + } + } + catch { } + } + } + + // Construct new version info + string packagePublicUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz"; + + var newVersionInfo = new VersionInfo + { + Version = version, + BuildDate = buildDateUtc, + GitCommit = gitCommit, + GitCommitShort = gitCommitShort, + PackageUrl = packagePublicUrl, + Sha256 = packageSha256, + SizeBytes = packageSizeBytes, + Changelog = changelog, + IsCritical = isCritical + }; + + // Remove duplicate version entry if re-publishing same version + channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase)); + channelManifest.Versions.Insert(0, newVersionInfo); + + // Keep last 15 releases + if (channelManifest.Versions.Count > 15) + { + channelManifest.Versions = channelManifest.Versions.Take(15).ToList(); + } + + channelManifest.Latest = channelManifest.Versions.FirstOrDefault(); + + string updatedLatestJson = JsonSerializer.Serialize(channelManifest, new JsonSerializerOptions { WriteIndented = true }); + string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json"); + await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson); + + await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite); + Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!"); + + await ftp.Disconnect(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[WARNING] FTP upload encountered error: {ex.Message}"); + Console.ResetColor(); + } + + // 6. Notify Deploymentcenter Web API + try + { + using var http = new HttpClient(); + string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/index.php"; + var payload = new + { + action = "publish_release", + product_slug = project, + version = version, + channel = channel, + release_notes = changelog, + download_url = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz", + sha256_hash = packageSha256, + git_commit = gitCommitShort, + size_bytes = packageSizeBytes, + is_critical = isCritical ? 1 : 0 + }; + + string jsonContent = JsonSerializer.Serialize(payload); + var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + var response = await http.PostAsync(apiPublishUrl, content); + if (response.IsSuccessStatusCode) + { + Console.WriteLine("[SUCCESS] Notified Deploymentcenter Web API of new release."); + } + } + catch { } + + // Cleanup temp + try { Directory.Delete(outputTempDir, true); } catch { } + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"\n[FINISHED] Release v{version} for {project} ({channel}) successfully published!"); + Console.ResetColor(); + return 0; + } + + static PackagerConfig LoadConfig(string path) + { + if (File.Exists(path)) + { + try + { + string json = File.ReadAllText(path); + var cfg = JsonSerializer.Deserialize(json); + if (cfg != null) return cfg; + } + catch { } + } + return new PackagerConfig(); + } + + static bool IsExcluded(string relPath, List patterns) + { + string fileName = Path.GetFileName(relPath); + foreach (var pattern in patterns) + { + if (pattern.StartsWith("*.")) + { + string ext = pattern.Substring(1); + if (fileName.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true; + } + else if (pattern.Equals(relPath, StringComparison.OrdinalIgnoreCase) || pattern.Equals(fileName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + static string ComputeSha256(string file) + { + using var sha256 = SHA256.Create(); + using var stream = File.OpenRead(file); + byte[] hash = sha256.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + static string? GetArg(string[] args, string flagLong, string? flagShort = null) + { + for (int i = 0; i < args.Length - 1; i++) + { + if (args[i].Equals(flagLong, StringComparison.OrdinalIgnoreCase) || + (flagShort != null && args[i].Equals(flagShort, StringComparison.OrdinalIgnoreCase))) + { + return args[i + 1]; + } + } + return null; + } + + static bool HasFlag(string[] args, string flag) + { + return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase)); + } + + static string GetGitCommitLong() + { + try + { + var psi = new ProcessStartInfo("git", "rev-parse HEAD") { RedirectStandardOutput = true, UseShellExecute = false }; + using var p = Process.Start(psi); + string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? ""; + p?.WaitForExit(); + if (!string.IsNullOrEmpty(outStr)) return outStr; + } + catch { } + return "UNKNOWN_COMMIT"; + } + + static string GetGitCommitShort() + { + try + { + var psi = new ProcessStartInfo("git", "rev-parse --short HEAD") { RedirectStandardOutput = true, UseShellExecute = false }; + using var p = Process.Start(psi); + string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? ""; + p?.WaitForExit(); + if (!string.IsNullOrEmpty(outStr)) return outStr; + } + catch { } + return "UNKNOWN"; + } + } +} diff --git a/client-dotnet/Deploymentcenter.Packager/packager.config.json b/client-dotnet/Deploymentcenter.Packager/packager.config.json new file mode 100644 index 0000000..ef89c76 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Packager/packager.config.json @@ -0,0 +1,18 @@ +{ + "ftpHost": "www531.your-server.de", + "ftpPort": 21, + "ftpUser": "bergisnu_4", + "ftpPass": "o2#M*NN^5EsT", + "ftpRemoteBaseDir": "/public_html/releases", + "apiBaseUrl": "https://dc.mhdf.de", + "excludePatterns": [ + "*.pdb", + "*.xml", + "appsettings.Development.json", + "appsettings.Staging.json", + "*.log", + "logs/**", + "scratch/**", + "*.tmp" + ] +} diff --git a/client-dotnet/Deploymentcenter.TestClient/Program.cs b/client-dotnet/Deploymentcenter.TestClient/Program.cs index f5d4ecc..43e12f0 100644 --- a/client-dotnet/Deploymentcenter.TestClient/Program.cs +++ b/client-dotnet/Deploymentcenter.TestClient/Program.cs @@ -282,25 +282,25 @@ class Program private static async Task TestUpdateServiceModuleAsync() { Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("5. Teste Modul: UpdateService (Release Check)..."); + Console.WriteLine($"5. Teste Modul: UpdateService [BuildInfo: {Deploymentcenter.Client.Models.BuildInfo.Summary}]..."); Console.ResetColor(); try { - HttpResponseMessage response = await Client.GetAsync($"{BaseUrl}/api/updateservice/v1/check?product=myapp&version=1.0.0"); - string responseBody = await response.Content.ReadAsStringAsync(); + var updateClient = new UpdateClient(Client); + var result = await updateClient.CheckForUpdateAsync(BaseUrl, "myapp", "1.0.0", "prod"); - Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}"); + Console.WriteLine($" Result Message: {result.Message}"); - if (response.IsSuccessStatusCode) + if (result.UpdateAvailable) { Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine(" ✔ UpdateService Modul Test: ERFOLGREICH!"); + Console.WriteLine($" ✔ Neues Release v{result.LatestRelease?.Version} verfügbar! URL: {result.LatestRelease?.PackageUrl}"); } else { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine(" ✖ UpdateService Modul Test: FEHLER!"); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine(" ✔ UpdateService Client Test: OK (Anwendung ist auf dem neuesten Stand oder Check erfolgreich)"); } } catch (Exception ex) diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Deploymentcenter.UpdateAgent.csproj b/client-dotnet/Deploymentcenter.UpdateAgent/Deploymentcenter.UpdateAgent.csproj new file mode 100644 index 0000000..0df22a0 --- /dev/null +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Deploymentcenter.UpdateAgent.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + enable + Deploymentcenter.UpdateAgent + update-agent + + + + + + + + + + + diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs new file mode 100644 index 0000000..b4e4f81 --- /dev/null +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading.Tasks; +using Deploymentcenter.Client; +using Deploymentcenter.Client.Models; +using Spectre.Console; + +namespace Deploymentcenter.UpdateAgent +{ + class Program + { + private static readonly HttpClient HttpClient = new HttpClient(); + + static async Task Main(string[] args) + { + string project = GetArg(args, "--project", "-p") ?? "myapp"; + string channel = GetArg(args, "--channel", "-c") ?? "prod"; + string action = GetArg(args, "--action", "-a") ?? "interactive"; + string version = GetArg(args, "--version", "-v") ?? "latest"; + string targetDir = GetArg(args, "--target-dir", "-t") ?? AppDomain.CurrentDomain.BaseDirectory; + string baseUrl = GetArg(args, "--base-url") ?? "https://dc.mhdf.de"; + string restartApp = GetArg(args, "--restart") ?? ""; + + targetDir = Path.GetFullPath(targetDir); + + // Read current local manifest if present + string localManifestPath = Path.Combine(targetDir, "manifest.json"); + PackageManifest? currentManifest = null; + if (File.Exists(localManifestPath)) + { + try + { + string json = File.ReadAllText(localManifestPath); + currentManifest = JsonSerializer.Deserialize(json); + } + catch { } + } + + string currentVersion = currentManifest?.Version ?? "Unbekannt"; + string currentBuildDate = currentManifest?.BuildDate ?? "Unbekannt"; + string currentGit = currentManifest?.GitCommitShort ?? "Unbekannt"; + + if (action.Equals("interactive", StringComparison.OrdinalIgnoreCase)) + { + return await RunInteractiveMode(baseUrl, project, channel, currentVersion, currentBuildDate, currentGit, targetDir, restartApp); + } + + // CLI Mode + return action.ToLowerInvariant() switch + { + "check" => await DoCheck(baseUrl, project, channel, currentVersion), + "update" => await DoUpdateOrRepair(baseUrl, project, channel, version, targetDir, currentManifest, restartApp), + "repair" => await DoUpdateOrRepair(baseUrl, project, channel, currentVersion, targetDir, currentManifest, restartApp), + "list" => await DoList(baseUrl, project, channel), + _ => ShowHelp() + }; + } + + static async Task RunInteractiveMode( + string baseUrl, + string project, + string channel, + string currentVer, + string currentBuildDate, + string currentGit, + string targetDir, + string restartApp) + { + AnsiConsole.Write( + new FigletText("UpdateAgent") + .LeftJustified() + .Color(Color.DodgerBlue1)); + + var panel = new Panel( + $"[bold white]Projekt:[/] [cyan]{project}[/] [bold white]Kanal:[/] [yellow]{channel}[/]\n" + + $"[bold white]Installierte Version:[/] [green]{currentVer}[/] ({currentBuildDate}) [bold grey][Git: {currentGit}][/]\n" + + $"[bold white]Zielpfad:[/] [grey]{targetDir}[/]") + { + Header = new PanelHeader("[bold blue] Deploymentcenter Update Agent [/]"), + Border = BoxBorder.Rounded + }; + AnsiConsole.Write(panel); + + AnsiConsole.MarkupLine("\n[grey]Lade verfügbare Releases...[/]"); + + ReleaseManifest? releaseManifest = null; + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Verbinde mit LEMP Server...", async ctx => + { + releaseManifest = await FetchManifestAsync(baseUrl, project, channel); + }); + + if (releaseManifest == null || releaseManifest.Latest == null) + { + AnsiConsole.MarkupLine("[bold red]Fehler: Koppelung zum LEMP Release Server fehlgeschlagen oder kein Release gefunden.[/]"); + return 1; + } + + var latest = releaseManifest.Latest; + bool isUpdateAvailable = UpdateClient.IsVersionNewer(currentVer, latest.Version); + + var choices = new List(); + string latestLabel = $"[L] Latest ({latest.Version}) - {latest.BuildDate} " + (isUpdateAvailable ? "[bold green]← empfohlen[/]" : "[grey](aktuell)[/]"); + choices.Add(latestLabel); + + int idx = 1; + var verMap = new Dictionary(); + verMap["latest"] = latest; + + foreach (var ver in releaseManifest.Versions) + { + string key = $"{idx++}"; + string label = $"[{key}] Version {ver.Version} - {ver.BuildDate} [grey]({ver.GitCommitShort})[/]"; + if (ver.Version.Equals(currentVer, StringComparison.OrdinalIgnoreCase)) + { + label += " [cyan]← aktuell installiert[/]"; + } + choices.Add(label); + verMap[key] = ver; + } + + choices.Add("[R] Reparatur der aktuellen Version"); + choices.Add("[Q] Beenden"); + + var selected = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("\n[bold white]Bitte wählen Sie eine Aktion:[/]") + .PageSize(10) + .AddChoices(choices)); + + if (selected.StartsWith("[Q]")) + { + AnsiConsole.MarkupLine("[yellow]Vorgang abgebrochen.[/]"); + return 0; + } + + string targetVersion = "latest"; + if (selected.StartsWith("[R]")) + { + targetVersion = currentVer; + AnsiConsole.MarkupLine($"\n[bold yellow]Starte Reparatur der Version v{targetVersion}...[/]"); + } + else if (selected.StartsWith("[L]")) + { + targetVersion = "latest"; + AnsiConsole.MarkupLine($"\n[bold green]Starte Update auf Version v{latest.Version}...[/]"); + } + else + { + // Match index + foreach (var entry in verMap) + { + if (entry.Key != "latest" && selected.StartsWith($"[{entry.Key}]")) + { + targetVersion = entry.Value.Version; + break; + } + } + AnsiConsole.MarkupLine($"\n[bold green]Starte Installation von Version v{targetVersion}...[/]"); + } + + return await DoUpdateOrRepair(baseUrl, project, channel, targetVersion, targetDir, null, restartApp); + } + + static async Task DoCheck(string baseUrl, string project, string channel, string currentVer) + { + var updateClient = new UpdateClient(HttpClient); + var res = await updateClient.CheckForUpdateAsync(baseUrl, project, currentVer, channel); + if (res.UpdateAvailable && res.LatestRelease != null) + { + Console.WriteLine($"UPDATE_AVAILABLE: {res.LatestRelease.Version} (Current: {currentVer})"); + return 0; + } + Console.WriteLine("UP_TO_DATE"); + return 0; + } + + static async Task DoList(string baseUrl, string project, string channel) + { + var manifest = await FetchManifestAsync(baseUrl, project, channel); + if (manifest == null) + { + Console.WriteLine("ERROR: Could not fetch manifest."); + return 1; + } + + Console.WriteLine($"Releases for {project} [{channel}]:"); + foreach (var v in manifest.Versions) + { + Console.WriteLine($" - v{v.Version} ({v.BuildDate}) [Git: {v.GitCommitShort}] SHA: {v.Sha256}"); + } + return 0; + } + + static async Task DoUpdateOrRepair( + string baseUrl, + string project, + string channel, + string targetVersion, + string targetDir, + PackageManifest? currentManifest, + string restartApp) + { + string tempDir = Path.Combine(Path.GetTempPath(), "dc_update_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + try + { + var releaseManifest = await FetchManifestAsync(baseUrl, project, channel); + if (releaseManifest == null) + { + AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte nicht abgerufen werden.[/]"); + return 1; + } + + VersionInfo? targetRelease = null; + if (targetVersion.Equals("latest", StringComparison.OrdinalIgnoreCase)) + { + targetRelease = releaseManifest.Latest; + } + else + { + targetRelease = releaseManifest.Versions.FirstOrDefault(v => v.Version.Equals(targetVersion, StringComparison.OrdinalIgnoreCase)); + } + + if (targetRelease == null) + { + AnsiConsole.MarkupLine($"[bold red]Fehler: Version '{targetVersion}' wurde auf dem Server nicht gefunden.[/]"); + return 1; + } + + string pkgUrl = targetRelease.PackageUrl; + if (string.IsNullOrEmpty(pkgUrl)) + { + pkgUrl = $"{baseUrl.TrimEnd('/')}/releases/{project}/{channel}/{targetRelease.Version}/package.tar.gz"; + } + + string localPkgPath = Path.Combine(tempDir, "package.tar.gz"); + + // 1. Download Package + await AnsiConsole.Progress() + .Columns(new ProgressColumn[] + { + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new PercentageColumn(), + new RemainingTimeColumn(), + new SpinnerColumn(), + }) + .StartAsync(async ctx => + { + var downloadTask = ctx.AddTask($"[green]Lade Package v{targetRelease.Version} herunter...[/]"); + using var resp = await HttpClient.GetAsync(pkgUrl, HttpCompletionOption.ResponseHeadersRead); + resp.EnsureSuccessStatusCode(); + + long? totalBytes = resp.Content.Headers.ContentLength; + using var stream = await resp.Content.ReadAsStreamAsync(); + using var fileStream = File.Create(localPkgPath); + + byte[] buffer = new byte[81920]; + long readBytes = 0; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + await fileStream.WriteAsync(buffer, 0, bytesRead); + readBytes += bytesRead; + if (totalBytes.HasValue && totalBytes.Value > 0) + { + downloadTask.Value = ((double)readBytes / totalBytes.Value) * 100; + } + } + downloadTask.Value = 100; + }); + + // 2. Verify SHA256 + AnsiConsole.MarkupLine("[grey]Prüfe SHA256-Integrität des Pakets...[/]"); + string computedSha = ComputeSha256(localPkgPath); + if (!string.Equals(computedSha, targetRelease.Sha256, StringComparison.OrdinalIgnoreCase)) + { + AnsiConsole.MarkupLine($"[bold red]Sicherheitsfehler: SHA256-Hash stimmt nicht überein![/]"); + AnsiConsole.MarkupLine($"Erwartet: {targetRelease.Sha256}"); + AnsiConsole.MarkupLine($"Erhalten: {computedSha}"); + return 1; + } + AnsiConsole.MarkupLine("[bold green][✔] SHA256 Hash erfolgreich verifiziert.[/]"); + + // 3. Extract to temp staging directory + string extractDir = Path.Combine(tempDir, "extracted"); + Directory.CreateDirectory(extractDir); + + AnsiConsole.MarkupLine("[grey]Entpacke Archiv (tar.gz)...[/]"); + using (var fs = File.OpenRead(localPkgPath)) + using (var gz = new GZipStream(fs, CompressionMode.Decompress)) + { + TarFile.ExtractToDirectory(gz, extractDir, overwriteFiles: true); + } + + // 4. Verify extracted files against manifest.json + string extractedManifestPath = Path.Combine(extractDir, "manifest.json"); + if (File.Exists(extractedManifestPath)) + { + string mJson = await File.ReadAllTextAsync(extractedManifestPath); + var pkgManifest = JsonSerializer.Deserialize(mJson); + if (pkgManifest != null) + { + var integrity = UpdateClient.VerifyIntegrity(extractDir, pkgManifest); + if (!integrity.IsValid) + { + AnsiConsole.MarkupLine("[bold red]Fehler bei Dateivalidierung nach Entpacken![/]"); + foreach (var missing in integrity.MissingFiles) AnsiConsole.MarkupLine($" - Fehlt: {missing}"); + foreach (var corrupt in integrity.CorruptedFiles) AnsiConsole.MarkupLine($" - Beschädigt: {corrupt}"); + return 1; + } + AnsiConsole.MarkupLine($"[bold green][✔] {integrity.TotalCheckedFiles} Dateien gegen Manifest-Hashes verifiziert.[/]"); + } + } + + // 5. Apply Update (Atomic Replace with Backup) + AnsiConsole.MarkupLine("[grey]Übertrage neue Dateien in Zielverzeichnis...[/]"); + ApplyFiles(extractDir, targetDir); + + AnsiConsole.MarkupLine($"\n[bold green]🚀 Update auf Version v{targetRelease.Version} erfolgreich abgeschlossen![/]"); + + // 6. Restart App if requested + if (!string.IsNullOrEmpty(restartApp) && File.Exists(restartApp)) + { + AnsiConsole.MarkupLine($"[grey]Starte Hauptanwendung neu ({Path.GetFileName(restartApp)})...[/]"); + Process.Start(new ProcessStartInfo { FileName = restartApp, UseShellExecute = true }); + } + + return 0; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[bold red]Fehler während des Update-Vorgangs: {ex.Message}[/]"); + return 1; + } + finally + { + try { Directory.Delete(tempDir, true); } catch { } + } + } + + static void ApplyFiles(string sourceDir, string targetDir) + { + Directory.CreateDirectory(targetDir); + + // Copy recursively, overwriting files + foreach (string dirPath in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(dirPath.Replace(sourceDir, targetDir)); + } + + foreach (string newPath in Directory.GetFiles(sourceDir, "*.*", SearchOption.AllDirectories)) + { + string targetPath = newPath.Replace(sourceDir, targetDir); + File.Copy(newPath, targetPath, true); + } + } + + static async Task FetchManifestAsync(string baseUrl, string project, string channel) + { + try + { + string url = $"{baseUrl.TrimEnd('/')}/releases/{project}/{channel}/latest.json"; + var resp = await HttpClient.GetAsync(url); + if (resp.IsSuccessStatusCode) + { + string json = await resp.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json); + } + } + catch { } + return null; + } + + static string ComputeSha256(string file) + { + using var sha256 = SHA256.Create(); + using var stream = File.OpenRead(file); + byte[] hash = sha256.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + static string? GetArg(string[] args, string flagLong, string? flagShort = null) + { + for (int i = 0; i < args.Length - 1; i++) + { + if (args[i].Equals(flagLong, StringComparison.OrdinalIgnoreCase) || + (flagShort != null && args[i].Equals(flagShort, StringComparison.OrdinalIgnoreCase))) + { + return args[i + 1]; + } + } + return null; + } + + static int ShowHelp() + { + Console.WriteLine("Deploymentcenter Update Agent"); + Console.WriteLine("Usage: update-agent [options]"); + Console.WriteLine("Options:"); + Console.WriteLine(" --project, -p Project slug"); + Console.WriteLine(" --channel, -c Channel (prod, beta, dev)"); + Console.WriteLine(" --action, -a interactive | check | update | repair | list"); + Console.WriteLine(" --version, -v Target version or 'latest'"); + Console.WriteLine(" --target-dir, -t Directory to update"); + Console.WriteLine(" --restart Executable to restart upon completion"); + return 0; + } + } +} diff --git a/client-dotnet/Deploymentcenter.slnx b/client-dotnet/Deploymentcenter.slnx new file mode 100644 index 0000000..dd8acd9 --- /dev/null +++ b/client-dotnet/Deploymentcenter.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/BUGTRACKER_INTEGRATION_GUIDE.md b/docs/BUGTRACKER_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..8e67a75 --- /dev/null +++ b/docs/BUGTRACKER_INTEGRATION_GUIDE.md @@ -0,0 +1,156 @@ +# Deploymentcenter — Bugtracker, Feature-Tracker & Token Provisioning Guide + +> **Zielgruppe**: KI-Agenten & Softwareentwickler +> **Zweck**: Anleitung zur automatisierten Registrierung, Fehlererfassung, Feature-Einreichung und Behebung über das Deployment Center. + +--- + +## 1. Token-Architektur & Autonome Sub-Token-Erstellung + +Das Deployment Center nutzt eine hierarchische Token-Struktur: +- **Master-Token**: Wird im Deployment Center Admin-Dashboard pro Identität (z. B. Entwickler-Agent, Client-Anwendung, Host-Server) erstellt. +- **Sub-Token**: Wird von Anwendungen oder KI-Agenten autonom über die Provisioning-API angefordert und für API-Aufrufe genutzt. + +### Sub-Token Anfordern (`POST /api/tokens/v1/provision`) +Sendet einen HTTP POST Request mit dem Master-Token im `X-Master-Token` Header. + +```http +POST /api/tokens/v1/provision HTTP/1.1 +Host: dc.mhdf.de +Content-Type: application/json +X-Master-Token: dc_master_myapp_dev_agent_001 + +{ + "name": "Dev Workstation Agent #4", + "instance_id": "DEV-WORKSTATION-01", + "scopes": ["bugtracker:report", "bugtracker:manage"], + "environment": "development" +} +``` + +#### JSON Antwort: +```json +{ + "status": "success", + "sub_token": "dc_sub_5f8a2c1...", + "token_id": "tok_s_89a1b2c3", + "name": "Dev Workstation Agent #4", + "scopes": ["bugtracker:report", "bugtracker:manage"], + "environment": "development" +} +``` + +--- + +## 2. Bug & Feature Ingest API (`POST /api/bugtracker/v1/report`) + +Wird von Überwachungs-Agenten im laufenden Betrieb oder Entwickler-Agenten auf der Workstation genutzt. + +### Request Schema: +```json +{ + "project_slug": "myapp", + "type": "bug", // "bug" oder "feature_request" + "environment": "development", // "production", "development", "staging", "testing" + "severity": "high", // "low", "medium", "high", "critical" + "title": "NullReferenceException in UserAuthService.cs line 42", + "description": "Beim Login ohne gesetzte Session ist ein Unerwarteter Nullpointer-Fehler aufgetreten.", + "error_message": "NullReferenceException: Object reference not set to an instance of an object.", + "stack_trace": "at MyApp.Core.UserAuthService.ValidateToken(String token)...", + "build_version": "v1.4.2-dev", + "created_by": "agent:dev-monitor-01" +} +``` + +#### Besonderheiten: +- **Deduplizierung**: Bei Bugs berechnet das System automatisch einen Error-Hash. Tritt derselbe Fehler in derselben Umgebung erneut auf, wird kein neuer Bug angelegt, sondern `occurrence_count` hochgezählt. + +--- + +## 3. Geschützte Management & Resolution API (`/api/bugtracker/v1/manage`) + +Schnittstelle für KI-Behebungs-Agenten zum Abrufen offener Bugs, Schreiben von Diagnose-Notizen und Markieren als gelöst. + +**Header**: `Authorization: Bearer ` (Benötigt Scope `bugtracker:manage`). + +### 3.1 Offene Items Abfragen (`GET /api/bugtracker/v1/manage/index.php`) +Filter-Parameter: `environment` (`development`/`production`), `type` (`bug`/`feature_request`), `status` (`open`/`in_progress`/`resolved`). + +### 3.2 Item-Details & Kommentar-Historie (`GET /api/bugtracker/v1/manage/index.php?id=123`) + +### 3.3 Ermittlungsschritt / Kommentar Hinzufügen (`POST /api/bugtracker/v1/manage/index.php?action=comment&id=123`) +```json +{ + "comment": "Log-Analyse gestartet. Der Fehler tritt auf, wenn $_SESSION['dc_user_id'] nicht gesetzt ist.", + "author": "agent:code-fixer-01", + "action_taken": "investigated" +} +``` + +### 3.4 Item als Gelöst / Umgesetzt Markieren (`POST /api/bugtracker/v1/manage/index.php?action=resolve&id=123`) +```json +{ + "resolved_in_build": "v1.4.3", + "resolution_notes": "Null-Check für Session-Variable hinzugefügt.", + "author": "agent:code-fixer-01" +} +``` + +--- + +## 4. C# (.NET 8) Implementierungsbeispiel für KI-Agenten + +```csharp +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +public class DeploymentCenterBugtrackerClient +{ + private static readonly HttpClient HttpClient = new HttpClient(); + private const string BaseUrl = "https://dc.mhdf.de"; + + public static async Task IngestDevBugAsync(string masterToken, string title, string errorMessage, string stackTrace) + { + // 1. Sub-Token anfordern + var provisionReq = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/api/tokens/v1/provision") + { + Content = new StringContent(JsonSerializer.Serialize(new + { + client_name = "Dev Agent Client", + scopes = new[] { "bugtracker:report" }, + environment = "development" + }), Encoding.UTF8, "application/json") + }; + provisionReq.Headers.Add("X-Master-Token", masterToken); + + var provResponse = await HttpClient.SendAsync(provisionReq); + var provJson = await provResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(provJson); + string subToken = doc.RootElement.GetProperty("sub_token").GetString(); + + // 2. Bug melden + var reportReq = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/api/bugtracker/v1/report") + { + Content = new StringContent(JsonSerializer.Serialize(new + { + project_slug = "myapp", + type = "bug", + environment = "development", + severity = "high", + title = title, + error_message = errorMessage, + stack_trace = stackTrace, + build_version = "v1.4.2-dev", + created_by = "agent:dev-agent-01" + }), Encoding.UTF8, "application/json") + }; + reportReq.Headers.Add("Authorization", $"Bearer {subToken}"); + + var reportResponse = await HttpClient.SendAsync(reportReq); + Console.WriteLine($"Report Response: {reportResponse.StatusCode}"); + } +} +``` diff --git a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..a3aaeab --- /dev/null +++ b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md @@ -0,0 +1,133 @@ +# Deploymentcenter — UpdateService Integration & Deployment Guide + +Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API). + +--- + +## 1. Übersicht & Architektur + +- **Kein dauerhafter Background-Dienst**: Hauptanwendungen prüfen beim Start einmalig schnell und netzwerktolerant auf verfügbare Updates und Dateiintegrität. +- **Entkoppelte Ausführung**: Bei Handlungsbedarf beendet sich die Hauptanwendung sauber und übergibt die Kontrolle an den eigenständigen Console Agent (`update-agent.exe` / `update-agent`). +- **3-Kanal-System**: Kanäle `prod` (Produktiv), `beta` (Vorab-Test), `dev` (Entwicklung). +- **Statische LEMP-Verteilung**: Downloads und Versionen-Manifeste (`latest.json`, `manifest.json`, `package.tar.gz`) werden über Nginx extrem performant bereitgestellt. + +--- + +## 2. Integration in .NET Client-Anwendungen + +### A. Referenz auf `Deploymentcenter.Client` + +Binde das Projekt oder Paket `Deploymentcenter.Client` in deine Anwendung ein. + +```csharp +using Deploymentcenter.Client; +using Deploymentcenter.Client.Models; + +// Early Start Hook in Program.cs (oder App.xaml.cs) +var updateClient = new UpdateClient(); +var checkResult = await updateClient.CheckForUpdateAsync( + baseUrl: "https://dc.mhdf.de", + projectId: "myapp", + currentVersion: BuildInfo.Version, + channel: "prod" +); + +if (checkResult.UpdateAvailable) +{ + Console.WriteLine($"[UPDATE] Neues Release v{checkResult.LatestRelease.Version} verfügbar!"); + + // UpdateAgent starten und Hauptanwendung beenden + UpdateClient.LaunchUpdateAgent( + agentPath: "update-agent.exe", + projectId: "myapp", + channel: "prod", + action: "update", + version: "latest", + exitCurrentApp: true + ); +} +``` + +### B. MSBuild BuildInfo Generierung + +Binde das `Deploymentcenter.BuildInfo.targets` Script in deine `.csproj` ein, damit Version, UTC-Build-Datum und Git Commit-Hash automatisch zur Übersetzungszeit generiert werden: + +```xml + +``` + +--- + +## 3. Packaging & Deployment CLI (`pack-and-deploy`) + +Das Packaging-Tool verpackt den `dotnet publish`-Output, berechnet Hashes, erzeugt das `manifest.json` und lädt alles per FTP auf den LEMP-Server. + +### Aufruf-Beispiel: + +```bash +# Automatisierter Release-Publish via CLI +pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./bin/Release/net8.0/publish --changelog "Fehlerbehebungen und Performance-Optimierung" +``` + +### Konfiguration (`packager.config.json`): + +```json +{ + "ftpHost": "www531.your-server.de", + "ftpPort": 21, + "ftpUser": "bergisnu_4", + "ftpPass": "o2#M*NN^5EsT", + "ftpRemoteBaseDir": "/public_html/releases", + "apiBaseUrl": "https://dc.mhdf.de", + "excludePatterns": [ + "*.pdb", + "*.xml", + "appsettings.Development.json", + "*.log", + "logs/*" + ] +} +``` + +--- + +## 4. Standalone UpdateAgent (`update-agent`) + +Der `update-agent` kann sowohl interaktiv (Spectre.Console Terminal UI) als auch im Headless CLI-Modus betrieben werden. + +### CLI Modus Befehle: + +```bash +# Nach Updates suchen +update-agent --project myapp --channel prod --action check + +# Auf neueste Version aktualisieren +update-agent --project myapp --channel prod --action update --version latest --target-dir /opt/myapp + +# Rollback auf ältere Version +update-agent --project myapp --channel prod --action update --version 1.3.2 + +# Integritäts-Reparatur der aktuellen Installation +update-agent --project myapp --channel prod --action repair + +# Alle verfügbaren Versionen auflisten +update-agent --project myapp --channel prod --action list +``` + +--- + +## 5. LEMP Verzeichnisstruktur auf dem Server + +```text +/var/www/releases/ (oder /public_html/releases/) +└── {ProjectId}/ # z.B. myapp, polytrader + ├── prod/ + │ ├── latest.json # Kanal-Übersicht & neueste Version + │ ├── 1.4.0/ + │ │ ├── package.tar.gz # Das gezippte Release + │ │ ├── package.tar.gz.sha256 + │ │ └── manifest.json # Einzeldateien + Hashes + │ └── 1.3.9/ + ├── beta/ + └── dev/ +``` diff --git a/public/api/bugtracker/v1/manage/.htaccess b/public/api/bugtracker/v1/manage/.htaccess new file mode 100644 index 0000000..59fe54b --- /dev/null +++ b/public/api/bugtracker/v1/manage/.htaccess @@ -0,0 +1,9 @@ +# Bugtracker Protected Management API (.htaccess Security Layer) +# Allows authenticated API token requests or session-authenticated admin users + +Satisfy Any +Allow from all + + + Require all granted + diff --git a/public/api/bugtracker/v1/manage/index.php b/public/api/bugtracker/v1/manage/index.php new file mode 100644 index 0000000..55df494 --- /dev/null +++ b/public/api/bugtracker/v1/manage/index.php @@ -0,0 +1,193 @@ +validateToken($token, 'bugtracker:manage'); + if ($tokenInfo) { + $isAuthenticated = true; + $authorName = 'agent:' . ($tokenInfo['name'] ?? $tokenInfo['token_id']); + } + } + } + + if (!$isAuthenticated) { + http_response_code(401); + echo json_encode(['status' => 'error', 'message' => 'Unauthorized: Valid Session or Bearer Token with scope bugtracker:manage required']); + exit; + } + + $repo = new BugRepo($db); + + $uri = $_SERVER['REQUEST_URI']; + $method = $_SERVER['REQUEST_METHOD']; + + $rawInput = file_get_contents('php://input'); + $input = json_decode($rawInput, true) ?: $_POST; + + // Parse sub-route if any + $path = parse_url($uri, PHP_URL_PATH); + $action = $_GET['action'] ?? null; + + // Handle Item Detail/Comment/Resolve via ID in URL or query params + $itemId = isset($_GET['id']) ? (int)$_GET['id'] : 0; + if (!$itemId && preg_match('/\/manage\/items\/(\d+)/', $path, $m)) { + $itemId = (int)$m[1]; + } + + // Sub-actions + if ($action === 'stats' || str_ends_with($path, '/stats')) { + echo json_encode(['status' => 'success', 'stats' => $repo->getStats()], JSON_PRETTY_PRINT); + exit; + } + + if ($action === 'resolve' || str_contains($path, '/resolve')) { + if ($method !== 'POST') { + http_response_code(405); + echo json_encode(['status' => 'error', 'message' => 'POST required for resolve']); + exit; + } + + if (!$itemId) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Missing item ID']); + exit; + } + + $build = !empty($input['resolved_in_build']) ? trim($input['resolved_in_build']) : 'v1.0.0'; + $notes = !empty($input['resolution_notes']) ? trim($input['resolution_notes']) : null; + $author = !empty($input['author']) ? trim($input['author']) : $authorName; + + $ok = $repo->resolveItem($itemId, $build, $notes, $author); + if ($ok) { + echo json_encode(['status' => 'success', 'message' => "Item #{$itemId} resolved in build {$build}"]); + } else { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Failed to resolve item']); + } + exit; + } + + if ($action === 'comment' || str_contains($path, '/comments')) { + if ($method !== 'POST') { + http_response_code(405); + echo json_encode(['status' => 'error', 'message' => 'POST required for comment']); + exit; + } + + if (!$itemId) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Missing item ID']); + exit; + } + + $comment = !empty($input['comment']) ? trim($input['comment']) : ''; + if (empty($comment)) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Comment cannot be empty']); + exit; + } + + $author = !empty($input['author']) ? trim($input['author']) : $authorName; + $actionTaken = !empty($input['action_taken']) ? trim($input['action_taken']) : 'commented'; + $meta = isset($input['meta']) && is_array($input['meta']) ? $input['meta'] : null; + + $comm = $repo->addComment($itemId, $author, $comment, $actionTaken, $meta); + echo json_encode(['status' => 'success', 'comment' => $comm]); + exit; + } + + if ($action === 'status' || str_contains($path, '/status')) { + if ($method !== 'POST') { + http_response_code(405); + echo json_encode(['status' => 'error', 'message' => 'POST required for status change']); + exit; + } + + if (!$itemId) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Missing item ID']); + exit; + } + + $status = !empty($input['status']) ? trim($input['status']) : 'open'; + $notes = !empty($input['notes']) ? trim($input['notes']) : null; + $author = !empty($input['author']) ? trim($input['author']) : $authorName; + + $ok = $repo->updateStatus($itemId, $status, $notes, $author); + if ($ok) { + echo json_encode(['status' => 'success', 'message' => "Status for #{$itemId} updated to {$status}"]); + } else { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Invalid status']); + } + exit; + } + + // Detail View of a single item + if ($itemId > 0 && $method === 'GET') { + $details = $repo->getItemDetails($itemId); + if (!$details) { + http_response_code(404); + echo json_encode(['status' => 'error', 'message' => 'Item not found']); + exit; + } + echo json_encode(['status' => 'success', 'item' => $details], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + exit; + } + + // Default: List Items + $filters = [ + 'project_slug' => $_GET['project_slug'] ?? $_GET['project'] ?? 'all', + 'environment' => $_GET['environment'] ?? $_GET['env'] ?? 'all', + 'type' => $_GET['type'] ?? 'all', + 'status' => $_GET['status'] ?? 'all', + 'severity' => $_GET['severity'] ?? 'all', + 'search' => $_GET['search'] ?? $_GET['q'] ?? '', + ]; + + $items = $repo->getItems($filters); + echo json_encode([ + 'status' => 'success', + 'count' => count($items), + 'filters' => $filters, + 'items' => $items, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + +} catch (Throwable $t) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'Manage API Error: ' . $t->getMessage()]); +} diff --git a/public/api/bugtracker/v1/report.php b/public/api/bugtracker/v1/report.php new file mode 100644 index 0000000..7a18825 --- /dev/null +++ b/public/api/bugtracker/v1/report.php @@ -0,0 +1,82 @@ + 'error', 'message' => 'Method Not Allowed']); + exit; +} + +$rawInput = file_get_contents('php://input'); +$data = json_decode($rawInput, true) ?: $_POST; + +if (empty($data)) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Empty request body or invalid JSON']); + exit; +} + +try { + $config = require __DIR__ . '/../../../../config/config.php'; + $db = Db::connect($config['db']); + + // Optional Token Verification (if provided) + $headers = getallheaders(); + $token = $headers['X-Agent-Token'] ?? $headers['x-agent-token'] ?? null; + if (!$token && !empty($headers['Authorization'])) { + if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) { + $token = trim($matches[1]); + } + } + + if ($token) { + $tokenMgr = new TokenManager($db); + $valid = $tokenMgr->validateToken($token, 'bugtracker:report', $data['environment'] ?? null); + if (!$valid) { + http_response_code(401); + echo json_encode(['status' => 'error', 'message' => 'Invalid, revoked or unauthorized Token for bugtracker:report']); + exit; + } + } + + $repo = new BugRepo($db); + $result = $repo->reportItem($data); + + echo json_encode([ + 'status' => 'success', + 'item_id' => $result['id'], + 'is_new' => $result['is_new'], + 'occurrence_count' => $result['occurrence_count'], + 'error_hash' => $result['error_hash'], + 'type' => $result['type'], + 'environment' => $result['environment'], + 'message' => $result['is_new'] + ? ($result['type'] === 'bug' ? 'New bug reported successfully.' : 'New feature request submitted.') + : 'Recurring bug count updated.', + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + +} catch (Throwable $t) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'Failed to log report: ' . $t->getMessage()]); +} diff --git a/public/api/tokens/v1/provision.php b/public/api/tokens/v1/provision.php new file mode 100644 index 0000000..ecc84ee --- /dev/null +++ b/public/api/tokens/v1/provision.php @@ -0,0 +1,77 @@ + 'error', 'message' => 'Method Not Allowed']); + exit; +} + +// Extract Master Token from Headers +$headers = getallheaders(); +$masterToken = $headers['X-Master-Token'] ?? $headers['x-master-token'] ?? null; + +if (!$masterToken && !empty($headers['Authorization'])) { + if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) { + $masterToken = trim($matches[1]); + } +} + +$rawInput = file_get_contents('php://input'); +$data = json_decode($rawInput, true) ?: $_POST; + +if (!$masterToken && !empty($data['master_token'])) { + $masterToken = trim($data['master_token']); +} + +if (!$masterToken) { + http_response_code(401); + echo json_encode(['status' => 'error', 'message' => 'Missing Master Token in X-Master-Token header or Authorization Bearer header']); + exit; +} + +try { + $config = require __DIR__ . '/../../../../config/config.php'; + $db = Db::connect($config['db']); + $tokenMgr = new TokenManager($db); + + $name = !empty($data['client_name']) ? trim($data['client_name']) : (!empty($data['name']) ? trim($data['name']) : 'Auto-Provisioned Agent Sub-Token'); + $instanceIdentity = !empty($data['instance_id']) ? trim($data['instance_id']) : (!empty($data['hostname']) ? trim($data['hostname']) : null); + $requestedScopes = isset($data['scopes']) && is_array($data['scopes']) ? $data['scopes'] : []; + $environment = !empty($data['environment']) ? trim($data['environment']) : 'all'; + + $subTokenData = $tokenMgr->provisionSubToken( + $masterToken, + $name, + $instanceIdentity, + $requestedScopes, + $environment + ); + + echo json_encode([ + 'status' => 'success', + 'sub_token' => $subTokenData['raw_token'], + 'token_id' => $subTokenData['token_id'], + 'name' => $subTokenData['name'], + 'scopes' => $subTokenData['scopes'], + 'environment' => $subTokenData['environment'], + 'type' => 'sub', + 'created_at' => date('Y-m-d H:i:s'), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + +} catch (InvalidArgumentException $e) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); +} catch (Throwable $t) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'Internal server error: ' . $t->getMessage()]); +} diff --git a/public/api/updateservice/v1/index.php b/public/api/updateservice/v1/index.php index 13ada8b..66fd921 100644 --- a/public/api/updateservice/v1/index.php +++ b/public/api/updateservice/v1/index.php @@ -25,15 +25,51 @@ try { $updateMgr = new UpdateManager($pdo); + // Read JSON body for POST requests if available + $inputData = []; + if ($method === 'POST') { + $raw = file_get_contents('php://input'); + if (!empty($raw)) { + $inputData = json_decode($raw, true) ?? []; + } + } + + $action = $_REQUEST['action'] ?? $inputData['action'] ?? ''; + + // Action: Publish Release (from Packager CLI) + if ($action === 'publish_release' && $method === 'POST') { + $product = $inputData['product_slug'] ?? $_POST['product_slug'] ?? ''; + $version = $inputData['version'] ?? $_POST['version'] ?? ''; + $channel = $inputData['channel'] ?? $_POST['channel'] ?? 'prod'; + $url = $inputData['download_url'] ?? $_POST['download_url'] ?? ''; + $hash = $inputData['sha256_hash'] ?? $_POST['sha256_hash'] ?? null; + $gitCommit = $inputData['git_commit'] ?? $_POST['git_commit'] ?? null; + $sizeBytes = (int)($inputData['size_bytes'] ?? $_POST['size_bytes'] ?? 0); + $notes = $inputData['release_notes'] ?? $_POST['release_notes'] ?? null; + $isCritical= !empty($inputData['is_critical']) || !empty($_POST['is_critical']); + + if (empty($product) || empty($version) || empty($url)) { + sendResponse(['error' => 'Bad Request', 'message' => 'Missing required fields: product_slug, version, download_url'], 400); + } + + $ok = $updateMgr->addRelease($product, $version, $channel, $notes, $url, $hash, $gitCommit, $sizeBytes, null, $isCritical); + if ($ok) { + sendResponse(['status' => 'success', 'message' => "Release v{$version} published for {$product} ({$channel})."]); + } else { + sendResponse(['error' => 'Database Error', 'message' => 'Failed to store release.'], 500); + } + } + if (str_ends_with($uri, '/check') && ($method === 'GET' || $method === 'POST')) { $product = $_REQUEST['product'] ?? $_REQUEST['product_slug'] ?? ''; $version = $_REQUEST['version'] ?? $_REQUEST['current_version'] ?? '0.0.0'; + $channel = $_REQUEST['channel'] ?? 'prod'; if (empty($product)) { sendResponse(['error' => 'Bad Request', 'message' => 'Parameter "product" is required.'], 400); } - $latest = $updateMgr->checkUpdate($product, $version); + $latest = $updateMgr->checkUpdate($product, $version, $channel); if ($latest) { sendResponse([ 'update_available' => true, @@ -49,7 +85,8 @@ try { if (str_ends_with($uri, '/releases') && $method === 'GET') { $product = $_GET['product'] ?? null; - $releases = $updateMgr->getReleases($product); + $channel = $_GET['channel'] ?? null; + $releases = $updateMgr->getReleases($product, $channel); sendResponse(['count' => count($releases), 'releases' => $releases]); } diff --git a/public/index.php b/public/index.php index 9f4de0c..dde45c0 100644 --- a/public/index.php +++ b/public/index.php @@ -9,15 +9,19 @@ require_once __DIR__ . '/../src/Modules/License/LicenseService.php'; require_once __DIR__ . '/../src/Modules/Watchdog/MonitorRepo.php'; require_once __DIR__ . '/../src/Modules/Watchdog/EventLog.php'; require_once __DIR__ . '/../src/Modules/Watchdog/TokenManager.php'; +require_once __DIR__ . '/../src/Core/TokenManager.php'; require_once __DIR__ . '/../src/Modules/UpdateService/UpdateManager.php'; +require_once __DIR__ . '/../src/Modules/Bugtracker/BugRepo.php'; use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Auth; +use Deploymentcenter\Core\TokenManager as CoreTokenManager; use Deploymentcenter\Modules\License\KeyGen; use Deploymentcenter\Modules\Watchdog\MonitorRepo; use Deploymentcenter\Modules\Watchdog\EventLog; use Deploymentcenter\Modules\Watchdog\TokenManager; use Deploymentcenter\Modules\UpdateService\UpdateManager; +use Deploymentcenter\Modules\Bugtracker\BugRepo; Auth::requireLogin(); @@ -388,21 +392,123 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($action === 'add_release') { $productSlug = trim($_POST['product_slug'] ?? ''); $version = trim($_POST['version'] ?? ''); + $channel = trim($_POST['channel'] ?? 'prod'); $url = trim($_POST['download_url'] ?? ''); $hash = trim($_POST['sha256_hash'] ?? ''); + $gitCommit = trim($_POST['git_commit'] ?? ''); + $sizeBytes = (int)($_POST['size_bytes'] ?? 0); $notes = trim($_POST['release_notes'] ?? ''); $critical = isset($_POST['is_critical']); if ($productSlug && $version && $url) { $updMgr = new UpdateManager($pdo); - if ($updMgr->addRelease($productSlug, $version, $notes, $url, $hash, $critical)) { - $msg = "Release v{$version} für Projekt '{$productSlug}' veröffentlicht."; + if ($updMgr->addRelease($productSlug, $version, $channel, $notes, $url, $hash, $gitCommit, $sizeBytes, null, $critical)) { + $msg = "Release v{$version} ({$channel}) für Projekt '{$productSlug}' veröffentlicht."; } else { $msg = "Fehler beim Speichern des Releases."; $msgType = 'danger'; } } } + + // Core Master & Sub Token Management Actions + if ($action === 'create_master_token') { + $name = trim($_POST['name'] ?? ''); + $proj = trim($_POST['project_slug'] ?? ''); + $lic = trim($_POST['license_key'] ?? ''); + $ownerType = $_POST['owner_type'] ?? 'custom'; + $ownerIdentity = trim($_POST['owner_identity'] ?? ''); + $scopes = isset($_POST['scopes']) && is_array($_POST['scopes']) ? $_POST['scopes'] : ['*']; + $env = $_POST['environment'] ?? 'all'; + + if ($name) { + $coreTokenMgr = new CoreTokenManager($pdo); + $res = $coreTokenMgr->createMasterToken($name, $proj, $lic, $ownerType, $ownerIdentity, $scopes, $env); + $msg = "Master-Token '{$name}' erstellt! Raw Master-Token (einmalig kopieren): {$res['raw_token']}"; + } + } + + if ($action === 'revoke_core_token') { + $tokenId = trim($_POST['token_id'] ?? ''); + if ($tokenId) { + $coreTokenMgr = new CoreTokenManager($pdo); + $coreTokenMgr->revokeToken($tokenId); + $msg = "Token '{$tokenId}' und alle abgeleiteten Sub-Tokens wurden widerrufen."; + } + } + + // Bugtracker & Feature-Tracker Actions + if ($action === 'bt_add_comment') { + $itemId = (int)($_POST['item_id'] ?? 0); + $comment = trim($_POST['comment'] ?? ''); + $author = trim($_POST['author'] ?? $_SESSION['dc_username'] ?? 'admin'); + $actionTaken = trim($_POST['action_taken'] ?? 'commented'); + + if ($itemId > 0 && !empty($comment)) { + $bugRepo = new BugRepo($pdo); + $bugRepo->addComment($itemId, $author, $comment, $actionTaken); + $msg = "Kommentar zu Item #{$itemId} hinzugefügt."; + } + } + + if ($action === 'bt_resolve') { + $itemId = (int)($_POST['item_id'] ?? 0); + $build = trim($_POST['resolved_in_build'] ?? 'v1.0.0'); + $notes = trim($_POST['resolution_notes'] ?? ''); + $author = trim($_POST['author'] ?? $_SESSION['dc_username'] ?? 'admin'); + + if ($itemId > 0 && !empty($build)) { + $bugRepo = new BugRepo($pdo); + $bugRepo->resolveItem($itemId, $build, $notes, $author); + $msg = "Item #{$itemId} wurde als gelöst/umgesetzt in Build '{$build}' markiert."; + } + } + + if ($action === 'bt_create_item') { + $proj = trim($_POST['project_slug'] ?? 'default'); + $type = $_POST['type'] ?? 'bug'; + $title = trim($_POST['title'] ?? ''); + $desc = trim($_POST['description'] ?? ''); + $errMsg = trim($_POST['error_message'] ?? ''); + $trace = trim($_POST['stack_trace'] ?? ''); + $build = trim($_POST['build_version'] ?? 'v1.0.0'); + $env = $_POST['environment'] ?? 'production'; + $sev = $_POST['severity'] ?? 'medium'; + $createdBy = trim($_POST['created_by'] ?? $_SESSION['dc_username'] ?? 'admin'); + + if ($title) { + $bugRepo = new BugRepo($pdo); + $res = $bugRepo->reportItem([ + 'project_slug' => $proj, + 'type' => $type, + 'title' => $title, + 'description' => $desc, + 'error_message' => $errMsg, + 'stack_trace' => $trace, + 'build_version' => $build, + 'environment' => $env, + 'severity' => $sev, + 'created_by' => $createdBy, + ]); + $msg = $res['is_new'] + ? "Neues Item #{$res['id']} ({$res['type']}) erfolgreich in {$env} erfasst." + : "Wiederkehrender Fehler erfasst. Occurrence Count erhöht auf {$res['occurrence_count']}."; + } + } + + if ($action === 'bt_change_status') { + $itemId = (int)($_POST['item_id'] ?? 0); + $status = $_POST['status'] ?? 'open'; + $notes = trim($_POST['notes'] ?? ''); + $author = trim($_POST['author'] ?? $_SESSION['dc_username'] ?? 'admin'); + + if ($itemId > 0) { + $bugRepo = new BugRepo($pdo); + $bugRepo->updateStatus($itemId, $status, $notes, $author); + $msg = "Status für Item #{$itemId} geändert auf {$status}."; + } + } +} } // Fetch All Data @@ -532,6 +638,16 @@ $recentEvents = $eventLog->getRecentEvents(100); $updateMgr = new UpdateManager($pdo); $releases = $updateMgr->getReleases(); +// Core Token Manager Data +$coreTokenMgr = new CoreTokenManager($pdo); +$coreMasterTokens = $coreTokenMgr->getAllMasterTokens(); +$coreAllTokens = $coreTokenMgr->getAllTokens(); + +// Bugtracker Data +$bugRepo = new BugRepo($pdo); +$bugtrackerStats = $bugRepo->getStats(); +$bugtrackerItems = $bugRepo->getItems(); + $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? 'dc.mhdf.de'; $baseUrl = $protocol . '://' . $host; @@ -740,6 +856,12 @@ $baseUrl = $protocol . '://' . $host; UpdateService + @@ -748,6 +870,12 @@ $baseUrl = $protocol . '://' . $host; + + +
+ +
+
+

👑 Master-Tokens (Selbst-Provisionierung für Client-Apps & Host-Skripte)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Token IDBezeichnungTyp / IdentitätProjekt / LizenzRechte (Scopes)Sub-TokensToken KeyAktionen
+
+ +
+ + Lic: ' . htmlspecialchars($mTok['license_key']) . '' : '' ?> + + + + + + Sub-Tokens + + + + + + + + +
+ + + +
+ + WIDERUFEN + +
+
+
+ +
+
+

🔑 Aktive Sub-Tokens (Per Provisioning API Erstellt)

+ + + + + + + + + + + + + + $t['type'] === 'sub'); + foreach ($subTokens as $sTok): + $scopesArr = json_decode($sTok['scopes'], true) ?: []; + ?> + + + + + + + + + + + +
Token IDParent Master IDBezeichnung / ClientUmgebungScopesZuletzt GenutztStatus
+ + + + + +
+
+
+ +
+
+

➕ Neuen Master-Token Erstellen

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + + +
+
+ + +
+
+
+ +
+
@@ -1756,6 +2304,15 @@ $baseUrl = $protocol . '://' . $host; { id: 'sub-update-releases', label: '📊 Releases Overview', active: true }, { id: 'sub-update-publish', label: '➕ Release Veröffentlichen' } ], + 'bugtracker': [ + { id: 'sub-bugtracker-items', label: '🐛 Bugs & Features', active: true }, + { id: 'sub-bugtracker-new', label: '➕ Item Anlegen' } + ], + 'tokens': [ + { id: 'sub-tokens-masters', label: '👑 Master-Tokens', active: true }, + { id: 'sub-tokens-subs', label: '🔑 Sub-Tokens' }, + { id: 'sub-tokens-create', label: '➕ Master-Token Erstellen' } + ], 'system': [ { id: 'sub-system-status', label: '⚙️ System-Status', active: true }, { id: 'sub-system-swagger', label: '📖 API Swagger Docs' }, @@ -1828,6 +2385,8 @@ $baseUrl = $protocol . '://' . $host; 'license': 'Lizenzverwaltung', 'watchdog': 'WatchDog Monitoring', 'updateservice': 'UpdateService Releases', + 'bugtracker': 'Bug- & Feature-Tracker', + 'tokens': 'Token-Verwaltung & Provisionierung', 'system': 'System & Datenbank Status' }; document.getElementById('topPageTitle').innerText = pageTitles[moduleName] || 'Deploymentcenter'; @@ -1835,6 +2394,128 @@ $baseUrl = $protocol . '://' . $host; location.hash = 'tab-' + moduleName; } + // Bugtracker Table Filter JS + function filterBugtrackerTable() { + const env = document.getElementById('btFilterEnv').value; + const type = document.getElementById('btFilterType').value; + const status = document.getElementById('btFilterStatus').value; + + document.querySelectorAll('#btTableBody .bt-row').forEach(row => { + const matchEnv = (env === 'all' || row.getAttribute('data-env') === env); + const matchType = (type === 'all' || row.getAttribute('data-type') === type); + const matchStatus = (status === 'all' || row.getAttribute('data-status') === status); + + if (matchEnv && matchType && matchStatus) { + row.style.display = ''; + } else { + row.style.display = 'none'; + } + }); + } + + // Open Bugtracker Item Details & Timeline Modal + function openBugtrackerModal(itemId) { + const modal = document.getElementById('btDetailModal'); + const content = document.getElementById('btModalContent'); + modal.style.display = 'flex'; + content.innerHTML = '
⏳ Lade Details & Timeline...
'; + + fetch(`api/bugtracker/v1/manage/index.php?id=${itemId}`) + .then(r => r.json()) + .then(res => { + if (!res.item) { + content.innerHTML = '
Fehler beim Laden des Items.
'; + return; + } + const item = res.item; + const comments = item.comments || []; + + let commentsHtml = ''; + if (comments.length === 0) { + commentsHtml = '

Noch keine Kommentare oder Ermittlungsschritte hinterlegt.

'; + } else { + comments.forEach(c => { + commentsHtml += ` +
+
+ ${c.author} + ${c.created_at} +
+
${c.comment}
+
+ `; + }); + } + + content.innerHTML = ` +
+
+ ${item.type.toUpperCase()} + ${item.environment.toUpperCase()} +

#${item.id}: ${item.title}

+
+ Projekt: ${item.project_slug} | Build: ${item.build_version || 'v1.0.0'} | Gemeldet von: ${item.created_by} +
+
+ +
+ + ${item.description ? ` +
+ Beschreibung: +
${item.description}
+
+ ` : ''} + + ${item.error_message ? ` +
+ Fehlermeldung: +
${item.error_message}
+
+ ` : ''} + + ${item.stack_trace ? ` +
+ Stacktrace: +
${item.stack_trace}
+
+ ` : ''} + +
+ +

📜 Agenten-Historie & Kommentar-Timeline

+
${commentsHtml}
+ +
+ + +
+ + +
+ +
+ `; + }) + .catch(err => { + content.innerHTML = `
Fehler beim Laden: ${err.message}
`; + }); + } + + function closeBugtrackerModal() { + document.getElementById('btDetailModal').style.display = 'none'; + } + + function openResolveModal(itemId, title) { + document.getElementById('resolveItemId').value = itemId; + document.getElementById('resolveItemTitle').innerText = title; + document.getElementById('btResolveModal').style.display = 'flex'; + } + + function closeResolveModal() { + document.getElementById('btResolveModal').style.display = 'none'; + } + // Horizontal Submenu Switcher in Top Bar function switchSubTab(moduleName, subtabId, el) { const parentModule = document.getElementById('tab-' + moduleName); @@ -2021,5 +2702,41 @@ $baseUrl = $protocol . '://' . $host; } }); + + + + + + diff --git a/scripts/deploy.py b/scripts/deploy.py index 502662c..23d9b55 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -17,7 +17,9 @@ IGNORE_PATTERNS = { 'scripts', '.deploy_cache.json', 'Serverdaten.txt', - 'Serverdaten.txt.bak' + 'Serverdaten.txt.bak', + 'bin', + 'obj' } def load_config(): @@ -65,9 +67,9 @@ def should_ignore(rel_path): parts = Path(rel_path).parts if not parts: return False - if parts[0] in IGNORE_PATTERNS: - return True for part in parts: + if part in IGNORE_PATTERNS: + return True if part == '.htaccess': continue if part.startswith('.'): diff --git a/sql/migrations/004_unified_tokens_and_bugtracker.sql b/sql/migrations/004_unified_tokens_and_bugtracker.sql new file mode 100644 index 0000000..01de2c1 --- /dev/null +++ b/sql/migrations/004_unified_tokens_and_bugtracker.sql @@ -0,0 +1,114 @@ +-- Migration 004: Unified Tokens (Master & Sub-Tokens) and Bugtracker / Feature-Tracker Module + +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS bugtracker_comments; +DROP TABLE IF EXISTS bugtracker_items; +DROP TABLE IF EXISTS dc_tokens; + +SET FOREIGN_KEY_CHECKS = 1; + +-- 1. Central Master & Sub-Token Hierarchy Table +CREATE TABLE dc_tokens ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + token_id VARCHAR(64) NOT NULL UNIQUE, + parent_token_id VARCHAR(64) NULL, + token_hash VARCHAR(128) NOT NULL, + raw_token VARCHAR(128) NULL, + name VARCHAR(100) NOT NULL, + project_slug VARCHAR(64) NULL, + license_key CHAR(29) NULL, + owner_type ENUM('license', 'project', 'host', 'dev_agent', 'custom') NOT NULL DEFAULT 'custom', + owner_identity VARCHAR(190) NULL, + type ENUM('master', 'sub') NOT NULL DEFAULT 'sub', + scopes JSON NOT NULL, + environment ENUM('production', 'development', 'all') NOT NULL DEFAULT 'all', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME NULL, + expires_at DATETIME NULL, + revoked TINYINT(1) NOT NULL DEFAULT 0, + FOREIGN KEY (parent_token_id) REFERENCES dc_tokens(token_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. Unified Bugtracker & Feature Request Items +CREATE TABLE bugtracker_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + project_id INT NULL, + project_slug VARCHAR(64) NOT NULL, + type ENUM('bug', 'feature_request') NOT NULL DEFAULT 'bug', + title VARCHAR(255) NOT NULL, + description TEXT NULL, + error_message TEXT NULL, + stack_trace TEXT NULL, + error_hash VARCHAR(64) NULL, + build_version VARCHAR(64) NULL, + environment ENUM('production', 'development', 'staging', 'testing') NOT NULL DEFAULT 'production', + severity ENUM('low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'medium', + status ENUM('open', 'planned', 'in_progress', 'resolved', 'closed', 'rejected') NOT NULL DEFAULT 'open', + occurrence_count INT NOT NULL DEFAULT 1, + first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at DATETIME NULL, + resolved_in_build VARCHAR(64) NULL, + resolution_notes TEXT NULL, + created_by VARCHAR(100) NOT NULL DEFAULT 'agent', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY ix_bt_proj_env (project_slug, environment, type, status), + KEY ix_bt_hash (error_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 3. Diagnostic Comments & Timeline Table +CREATE TABLE bugtracker_comments ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + item_id BIGINT NOT NULL, + author VARCHAR(100) NOT NULL, + comment TEXT NOT NULL, + action_taken VARCHAR(64) NULL, + meta_json JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (item_id) REFERENCES bugtracker_items(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Seed Sample Master Token +-- Raw Master Token: dc_master_myapp_dev_agent_001 +-- Hash: SHA256 of 'dc_master_myapp_dev_agent_001' +INSERT INTO dc_tokens ( + token_id, parent_token_id, token_hash, raw_token, name, project_slug, owner_type, owner_identity, type, scopes, environment +) VALUES ( + 'tok_m_myapp_dev', NULL, + '5a38a7c29e64e526c71c4c16a695123d6874e0d9bf0d768132049e8fa65e10aa', + 'dc_master_myapp_dev_agent_001', + 'MyApp Dev Workstation Master Key', + 'myapp', 'dev_agent', 'DEV-WORKSTATION-01', 'master', + '["bugtracker:report", "bugtracker:manage", "watchdog:ping", "updateservice:read"]', + 'all' +); + +-- Seed Sample Sub Token provisioned from Master Token +INSERT INTO dc_tokens ( + token_id, parent_token_id, token_hash, raw_token, name, project_slug, owner_type, owner_identity, type, scopes, environment +) VALUES ( + 'tok_s_myapp_agent_sub1', 'tok_m_myapp_dev', + '7c7bc0a5d4d3856b3e9447477c133a8a3a0e67617ab6f6d0a793a388b14a27bc', + 'dc_sub_myapp_agent_live_001', + 'Dev Agent Auto-Provisioned Token', + 'myapp', 'dev_agent', 'DEV-WORKSTATION-01', 'sub', + '["bugtracker:report", "bugtracker:manage"]', + 'development' +); + +-- Seed Sample Bugs and Feature Requests +INSERT INTO bugtracker_items ( + id, project_slug, type, title, description, error_message, stack_trace, error_hash, build_version, environment, severity, status, occurrence_count, created_by +) VALUES +(1, 'myapp', 'bug', 'NullReferenceException in UserAuthService.cs line 42', 'Beim Login ohne gesetzte Session ist ein Unerwarteter Nullpointer-Fehler aufgetreten.', 'NullReferenceException: Object reference not set to an instance of an object.', 'at MyApp.Core.UserAuthService.ValidateToken(String token) in UserAuthService.cs:line 42\nat MyApp.Controllers.AuthController.Login() in AuthController.cs:line 18', 'e2c918a514d89a42f', 'v1.4.2-dev', 'development', 'high', 'open', 3, 'agent:dev-monitor-01'), +(2, 'polytrader', 'bug', 'Database Connection Timeout on Order Execution', 'Unter hoher Last bricht die Verbindung zur MariaDB nach 30s ab.', 'SQLSTATE[HY000] [2002] Connection timed out', 'PDOException: SQLSTATE[HY000] [2002] Connection timed out at Db.php:24', 'f9a2b881c1092837d', 'v2.0.1', 'production', 'critical', 'in_progress', 14, 'agent:prod-watchdog'), +(3, 'myapp', 'feature_request', 'Unterstützung für TOTP 2FA Login erzwingen', 'KI-Agent empfiehlt die Hinzufügung einer erzwungenen Zwei-Faktor-Authentifizierung für Administrator-Konten.', NULL, NULL, NULL, 'v1.5.0-roadmap', 'development', 'medium', 'open', 1, 'agent:security-agent'); + +-- Seed Sample Comments +INSERT INTO bugtracker_comments ( + item_id, author, comment, action_taken, created_at +) VALUES +(1, 'agent:code-fixer-01', 'Log-Analyse gestartet. Der Fehler tritt auf, wenn $_SESSION["dc_user_id"] nicht gesetzt ist.', 'investigated', NOW()), +(2, 'agent:db-optimizer', 'Max Connection Limit in config.php auf 100 erhöht. Connection Pool wird beobachtet.', 'added_hint', NOW()); diff --git a/sql/schema.sql b/sql/schema.sql index 4abd8de..ab4e1f2 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -25,6 +25,11 @@ DROP TABLE IF EXISTS watchdog_monitors; -- UpdateService Module Tables DROP TABLE IF EXISTS updateservice_releases; +-- Bugtracker & Token Hierarchy Tables +DROP TABLE IF EXISTS bugtracker_comments; +DROP TABLE IF EXISTS bugtracker_items; +DROP TABLE IF EXISTS dc_tokens; + SET FOREIGN_KEY_CHECKS = 1; -- 1. Core Platform Tables @@ -191,12 +196,16 @@ CREATE TABLE updateservice_releases ( id INT AUTO_INCREMENT PRIMARY KEY, product_slug VARCHAR(64) NOT NULL, version VARCHAR(32) NOT NULL, + channel VARCHAR(32) NOT NULL DEFAULT 'prod', release_notes TEXT NULL, download_url VARCHAR(255) NOT NULL, sha256_hash VARCHAR(64) NULL, + git_commit VARCHAR(64) NULL, + size_bytes BIGINT NOT NULL DEFAULT 0, + manifest_json JSON NULL, is_critical TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uq_prod_ver (product_slug, version) + UNIQUE KEY uq_prod_ver_chan (product_slug, version, channel) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Seed default cron jobs for Watchdog @@ -265,3 +274,89 @@ INSERT INTO updateservice_releases (product_slug, version, release_notes, downlo ('myapp', '1.1.0', 'Fehlerbehebungen und Performance-Optimierung', 'https://dc.mhdf.de/downloads/myapp-1.1.0.zip', '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', 0), ('polytrader', '2.0.1', 'Kritisches Sicherheits-Update für Handelsverbindungen', 'https://dc.mhdf.de/downloads/polytrader-2.0.1.zip', '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8', 1) ON DUPLICATE KEY UPDATE release_notes = VALUES(release_notes); + +-- 5. Core Token Hierarchy & Bugtracker Tables +CREATE TABLE dc_tokens ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + token_id VARCHAR(64) NOT NULL UNIQUE, + parent_token_id VARCHAR(64) NULL, + token_hash VARCHAR(128) NOT NULL, + raw_token VARCHAR(128) NULL, + name VARCHAR(100) NOT NULL, + project_slug VARCHAR(64) NULL, + license_key CHAR(29) NULL, + owner_type ENUM('license', 'project', 'host', 'dev_agent', 'custom') NOT NULL DEFAULT 'custom', + owner_identity VARCHAR(190) NULL, + type ENUM('master', 'sub') NOT NULL DEFAULT 'sub', + scopes JSON NOT NULL, + environment ENUM('production', 'development', 'all') NOT NULL DEFAULT 'all', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME NULL, + expires_at DATETIME NULL, + revoked TINYINT(1) NOT NULL DEFAULT 0, + FOREIGN KEY (parent_token_id) REFERENCES dc_tokens(token_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE bugtracker_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + project_id INT NULL, + project_slug VARCHAR(64) NOT NULL, + type ENUM('bug', 'feature_request') NOT NULL DEFAULT 'bug', + title VARCHAR(255) NOT NULL, + description TEXT NULL, + error_message TEXT NULL, + stack_trace TEXT NULL, + error_hash VARCHAR(64) NULL, + build_version VARCHAR(64) NULL, + environment ENUM('production', 'development', 'staging', 'testing') NOT NULL DEFAULT 'production', + severity ENUM('low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'medium', + status ENUM('open', 'planned', 'in_progress', 'resolved', 'closed', 'rejected') NOT NULL DEFAULT 'open', + occurrence_count INT NOT NULL DEFAULT 1, + first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at DATETIME NULL, + resolved_in_build VARCHAR(64) NULL, + resolution_notes TEXT NULL, + created_by VARCHAR(100) NOT NULL DEFAULT 'agent', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY ix_bt_proj_env (project_slug, environment, type, status), + KEY ix_bt_hash (error_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE bugtracker_comments ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + item_id BIGINT NOT NULL, + author VARCHAR(100) NOT NULL, + comment TEXT NOT NULL, + action_taken VARCHAR(64) NULL, + meta_json JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (item_id) REFERENCES bugtracker_items(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Seed Initial Tokens +INSERT INTO dc_tokens ( + token_id, parent_token_id, token_hash, raw_token, name, project_slug, owner_type, owner_identity, type, scopes, environment +) VALUES +('tok_m_myapp_dev', NULL, '5a38a7c29e64e526c71c4c16a695123d6874e0d9bf0d768132049e8fa65e10aa', 'dc_master_myapp_dev_agent_001', 'MyApp Dev Workstation Master Key', 'myapp', 'dev_agent', 'DEV-WORKSTATION-01', 'master', '["bugtracker:report", "bugtracker:manage", "watchdog:ping", "updateservice:read"]', 'all'), +('tok_s_myapp_agent_sub1', 'tok_m_myapp_dev', '7c7bc0a5d4d3856b3e9447477c133a8a3a0e67617ab6f6d0a793a388b14a27bc', 'dc_sub_myapp_agent_live_001', 'Dev Agent Auto-Provisioned Token', 'myapp', 'dev_agent', 'DEV-WORKSTATION-01', 'sub', '["bugtracker:report", "bugtracker:manage"]', 'development') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +-- Seed Initial Bugtracker Items +INSERT INTO bugtracker_items ( + id, project_slug, type, title, description, error_message, stack_trace, error_hash, build_version, environment, severity, status, occurrence_count, created_by +) VALUES +(1, 'myapp', 'bug', 'NullReferenceException in UserAuthService.cs line 42', 'Beim Login ohne gesetzte Session ist ein Unerwarteter Nullpointer-Fehler aufgetreten.', 'NullReferenceException: Object reference not set to an instance of an object.', 'at MyApp.Core.UserAuthService.ValidateToken(String token) in UserAuthService.cs:line 42\nat MyApp.Controllers.AuthController.Login() in AuthController.cs:line 18', 'e2c918a514d89a42f', 'v1.4.2-dev', 'development', 'high', 'open', 3, 'agent:dev-monitor-01'), +(2, 'polytrader', 'bug', 'Database Connection Timeout on Order Execution', 'Unter hoher Last bricht die Verbindung zur MariaDB nach 30s ab.', 'SQLSTATE[HY000] [2002] Connection timed out', 'PDOException: SQLSTATE[HY000] [2002] Connection timed out at Db.php:24', 'f9a2b881c1092837d', 'v2.0.1', 'production', 'critical', 'in_progress', 14, 'agent:prod-watchdog'), +(3, 'myapp', 'feature_request', 'Unterstützung für TOTP 2FA Login erzwingen', 'KI-Agent empfiehlt die Hinzufügung einer erzwungenen Zwei-Faktor-Authentifizierung für Administrator-Konten.', NULL, NULL, NULL, 'v1.5.0-roadmap', 'development', 'medium', 'open', 1, 'agent:security-agent') +ON DUPLICATE KEY UPDATE title = VALUES(title); + +-- Seed Initial Comments +INSERT INTO bugtracker_comments ( + item_id, author, comment, action_taken, created_at +) VALUES +(1, 'agent:code-fixer-01', 'Log-Analyse gestartet. Der Fehler tritt auf, wenn $_SESSION["dc_user_id"] nicht gesetzt ist.', 'investigated', NOW()), +(2, 'agent:db-optimizer', 'Max Connection Limit in config.php auf 100 erhöht. Connection Pool wird beobachtet.', 'added_hint', NOW()) +ON DUPLICATE KEY UPDATE comment = VALUES(comment); + diff --git a/src/Core/TokenManager.php b/src/Core/TokenManager.php new file mode 100644 index 0000000..9c98973 --- /dev/null +++ b/src/Core/TokenManager.php @@ -0,0 +1,233 @@ +db = $db; + } + + /** + * Create a new Master Token. + */ + public function createMasterToken( + string $name, + ?string $projectSlug = null, + ?string $licenseKey = null, + string $ownerType = 'custom', + ?string $ownerIdentity = null, + array $scopes = ['*'], + string $environment = 'all' + ): array { + $tokenId = 'tok_m_' . bin2hex(random_bytes(8)); + $rawToken = 'dc_master_' . bin2hex(random_bytes(20)); + $tokenHash = hash('sha256', $rawToken); + + $stmt = $this->db->prepare(' + INSERT INTO dc_tokens ( + token_id, parent_token_id, token_hash, raw_token, name, + project_slug, license_key, owner_type, owner_identity, + type, scopes, environment, created_at + ) VALUES ( + :id, NULL, :hash, :raw, :name, + :proj, :lic, :type, :identity, + "master", :scopes, :env, NOW() + ) + '); + + $stmt->execute([ + ':id' => $tokenId, + ':hash' => $tokenHash, + ':raw' => $rawToken, + ':name' => $name, + ':proj' => !empty($projectSlug) ? $projectSlug : null, + ':lic' => !empty($licenseKey) ? $licenseKey : null, + ':type' => in_array($ownerType, ['license', 'project', 'host', 'dev_agent', 'custom']) ? $ownerType : 'custom', + ':identity' => !empty($ownerIdentity) ? $ownerIdentity : null, + ':scopes' => json_encode(!empty($scopes) ? $scopes : ['*']), + ':env' => in_array($environment, ['production', 'development', 'all']) ? $environment : 'all', + ]); + + return [ + 'token_id' => $tokenId, + 'raw_token' => $rawToken, + 'name' => $name, + 'type' => 'master', + ]; + } + + /** + * Provision a Sub-Token using a Master-Token. + */ + public function provisionSubToken( + string $rawMasterToken, + string $name, + ?string $instanceIdentity = null, + array $requestedScopes = [], + string $environment = 'all' + ): array { + $masterHash = hash('sha256', $rawMasterToken); + + $stmt = $this->db->prepare(' + SELECT * FROM dc_tokens + WHERE (token_hash = :hash OR raw_token = :raw) + AND type = "master" + AND revoked = 0 + '); + $stmt->execute([':hash' => $masterHash, ':raw' => $rawMasterToken]); + $master = $stmt->fetch(); + + if (!$master) { + throw new \InvalidArgumentException('Invalid or revoked Master Token.'); + } + + $masterScopes = json_decode($master['scopes'], true) ?: ['*']; + + // Determine effective scopes + $effectiveScopes = []; + if (in_array('*', $masterScopes)) { + $effectiveScopes = !empty($requestedScopes) ? $requestedScopes : ['*']; + } else { + if (empty($requestedScopes)) { + $effectiveScopes = $masterScopes; + } else { + $effectiveScopes = array_intersect($requestedScopes, $masterScopes); + } + } + + if (empty($effectiveScopes)) { + throw new \InvalidArgumentException('Requested scopes are not allowed by this Master Token.'); + } + + // Determine effective environment + $effectiveEnv = $environment; + if ($master['environment'] !== 'all') { + $effectiveEnv = $master['environment']; + } + + $subTokenId = 'tok_s_' . bin2hex(random_bytes(8)); + $rawSubToken = 'dc_sub_' . bin2hex(random_bytes(20)); + $subHash = hash('sha256', $rawSubToken); + + $ins = $this->db->prepare(' + INSERT INTO dc_tokens ( + token_id, parent_token_id, token_hash, raw_token, name, + project_slug, license_key, owner_type, owner_identity, + type, scopes, environment, created_at + ) VALUES ( + :id, :parent_id, :hash, :raw, :name, + :proj, :lic, :owner_type, :identity, + "sub", :scopes, :env, NOW() + ) + '); + + $ins->execute([ + ':id' => $subTokenId, + ':parent_id' => $master['token_id'], + ':hash' => $subHash, + ':raw' => $rawSubToken, + ':name' => $name, + ':proj' => $master['project_slug'], + ':lic' => $master['license_key'], + ':owner_type'=> $master['owner_type'], + ':identity' => !empty($instanceIdentity) ? $instanceIdentity : $master['owner_identity'], + ':scopes' => json_encode(array_values($effectiveScopes)), + ':env' => $effectiveEnv, + ]); + + return [ + 'token_id' => $subTokenId, + 'raw_token' => $rawSubToken, + 'name' => $name, + 'scopes' => array_values($effectiveScopes), + 'environment'=> $effectiveEnv, + 'type' => 'sub', + ]; + } + + /** + * Validate any Token (Master or Sub) and check cascading revocation of parent tokens. + */ + public function validateToken(string $rawToken, ?string $requiredScope = null, ?string $environment = null): ?array + { + $hash = hash('sha256', $rawToken); + + $stmt = $this->db->prepare(' + SELECT t.*, p.revoked as parent_revoked + FROM dc_tokens t + LEFT JOIN dc_tokens p ON t.parent_token_id = p.token_id + WHERE (t.token_hash = :hash OR t.raw_token = :raw) + AND t.revoked = 0 + '); + $stmt->execute([':hash' => $hash, ':raw' => $rawToken]); + $token = $stmt->fetch(); + + if (!$token) { + return null; + } + + // Cascading Revocation Check + if ($token['type'] === 'sub' && !empty($token['parent_token_id']) && (int)$token['parent_revoked'] === 1) { + return null; + } + + // Scope Check + if ($requiredScope !== null) { + $scopes = json_decode($token['scopes'], true) ?: []; + if (!in_array('*', $scopes) && !in_array($requiredScope, $scopes)) { + return null; + } + } + + // Environment Check + if ($environment !== null && $token['environment'] !== 'all' && $token['environment'] !== $environment) { + return null; + } + + // Update Last Used Timestamp + $upd = $this->db->prepare('UPDATE dc_tokens SET last_used_at = NOW() WHERE id = :id'); + $upd->execute([':id' => $token['id']]); + + return $token; + } + + /** + * Revoke a Token (Master or Sub). If Master, cascade revokes all child Sub-Tokens via DB foreign key or query. + */ + public function revokeToken(string $tokenId): bool + { + $stmt = $this->db->prepare('UPDATE dc_tokens SET revoked = 1 WHERE token_id = :id OR parent_token_id = :id'); + return $stmt->execute([':id' => $tokenId]); + } + + /** + * Get all Master Tokens with child count. + */ + public function getAllMasterTokens(): array + { + $stmt = $this->db->query(' + SELECT m.*, COUNT(s.id) as sub_token_count + FROM dc_tokens m + LEFT JOIN dc_tokens s ON m.token_id = s.parent_token_id + WHERE m.type = "master" + GROUP BY m.id + ORDER BY m.created_at DESC + '); + return $stmt->fetchAll() ?: []; + } + + /** + * Get all Tokens (Master & Sub). + */ + public function getAllTokens(): array + { + $stmt = $this->db->query('SELECT * FROM dc_tokens ORDER BY created_at DESC'); + return $stmt->fetchAll() ?: []; + } +} diff --git a/src/Modules/Bugtracker/BugRepo.php b/src/Modules/Bugtracker/BugRepo.php new file mode 100644 index 0000000..a90713d --- /dev/null +++ b/src/Modules/Bugtracker/BugRepo.php @@ -0,0 +1,315 @@ +db = $db; + } + + /** + * Ingest a Bug or Feature Request. Automates error-hash deduplication for bugs. + */ + public function reportItem(array $data): array + { + $projectSlug = !empty($data['project_slug']) ? trim($data['project_slug']) : 'default'; + $type = (isset($data['type']) && $data['type'] === 'feature_request') ? 'feature_request' : 'bug'; + $title = !empty($data['title']) ? trim($data['title']) : ($type === 'bug' ? 'Unhandled Exception' : 'New Feature Request'); + $description = $data['description'] ?? null; + $errorMessage = $data['error_message'] ?? null; + $stackTrace = $data['stack_trace'] ?? null; + $buildVersion = $data['build_version'] ?? 'v1.0.0'; + $environment = in_array($data['environment'] ?? '', ['production', 'development', 'staging', 'testing']) + ? $data['environment'] + : 'production'; + $severity = in_array($data['severity'] ?? '', ['low', 'medium', 'high', 'critical']) + ? $data['severity'] + : 'medium'; + $createdBy = !empty($data['created_by']) ? trim($data['created_by']) : 'agent'; + + // Find associated project_id from dc_projects + $projStmt = $this->db->prepare('SELECT id FROM dc_projects WHERE slug = :slug'); + $projStmt->execute([':slug' => $projectSlug]); + $projectId = $projStmt->fetchColumn() ?: null; + + // Deduplication logic for Bugs + $errorHash = null; + if ($type === 'bug') { + $hashInput = $projectSlug . '|' . ($errorMessage ?: $title) . '|' . substr($stackTrace ?: '', 0, 200) . '|' . $environment; + $errorHash = substr(hash('sha256', $hashInput), 0, 24); + + $existingStmt = $this->db->prepare(' + SELECT id, occurrence_count + FROM bugtracker_items + WHERE error_hash = :hash + AND environment = :env + AND status IN ("open", "in_progress", "planned") + LIMIT 1 + '); + $existingStmt->execute([':hash' => $errorHash, ':env' => $environment]); + $existing = $existingStmt->fetch(); + + if ($existing) { + $newCount = (int)$existing['occurrence_count'] + 1; + $upd = $this->db->prepare(' + UPDATE bugtracker_items + SET occurrence_count = :count, last_seen_at = NOW() + WHERE id = :id + '); + $upd->execute([':count' => $newCount, ':id' => $existing['id']]); + + return [ + 'id' => (int)$existing['id'], + 'is_new' => false, + 'occurrence_count' => $newCount, + 'error_hash' => $errorHash, + 'type' => $type, + 'environment' => $environment, + ]; + } + } + + $ins = $this->db->prepare(' + INSERT INTO bugtracker_items ( + project_id, project_slug, type, title, description, + error_message, stack_trace, error_hash, build_version, + environment, severity, status, occurrence_count, + first_seen_at, last_seen_at, created_by, created_at + ) VALUES ( + :pid, :slug, :type, :title, :desc, + :err, :trace, :hash, :build, + :env, :sev, "open", 1, + NOW(), NOW(), :created_by, NOW() + ) + '); + + $ins->execute([ + ':pid' => $projectId, + ':slug' => $projectSlug, + ':type' => $type, + ':title' => $title, + ':desc' => $description, + ':err' => $errorMessage, + ':trace' => $stackTrace, + ':hash' => $errorHash, + ':build' => $buildVersion, + ':env' => $environment, + ':sev' => $severity, + ':created_by' => $createdBy, + ]); + + $newItemId = (int)$this->db->lastInsertId(); + + // Initial comment log + $this->addComment( + $newItemId, + $createdBy, + $type === 'bug' ? 'Bug in System erfasst.' : 'Feature-Request eingereicht.', + 'reported' + ); + + return [ + 'id' => $newItemId, + 'is_new' => true, + 'occurrence_count' => 1, + 'error_hash' => $errorHash, + 'type' => $type, + 'environment' => $environment, + ]; + } + + /** + * Get filtered list of Bugs & Feature Requests. + */ + public function getItems(array $filters = []): array + { + $where = []; + $params = []; + + if (!empty($filters['project_slug']) && $filters['project_slug'] !== 'all') { + $where[] = 'project_slug = :slug'; + $params[':slug'] = $filters['project_slug']; + } + + if (!empty($filters['environment']) && $filters['environment'] !== 'all') { + $where[] = 'environment = :env'; + $params[':env'] = $filters['environment']; + } + + if (!empty($filters['type']) && $filters['type'] !== 'all') { + $where[] = 'type = :type'; + $params[':type'] = $filters['type']; + } + + if (!empty($filters['status']) && $filters['status'] !== 'all') { + $where[] = 'status = :status'; + $params[':status'] = $filters['status']; + } + + if (!empty($filters['severity']) && $filters['severity'] !== 'all') { + $where[] = 'severity = :severity'; + $params[':severity'] = $filters['severity']; + } + + if (!empty($filters['search'])) { + $where[] = '(title LIKE :q OR description LIKE :q OR error_message LIKE :q)'; + $params[':q'] = '%' . trim($filters['search']) . '%'; + } + + $sql = 'SELECT * FROM bugtracker_items'; + if (!empty($where)) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= ' ORDER BY last_seen_at DESC, id DESC'; + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll() ?: []; + } + + /** + * Get single Item details including complete comment history. + */ + public function getItemDetails(int $id): ?array + { + $stmt = $this->db->prepare('SELECT * FROM bugtracker_items WHERE id = :id'); + $stmt->execute([':id' => $id]); + $item = $stmt->fetch(); + + if (!$item) { + return null; + } + + $commStmt = $this->db->prepare('SELECT * FROM bugtracker_comments WHERE item_id = :id ORDER BY created_at ASC'); + $commStmt->execute([':id' => $id]); + $item['comments'] = $commStmt->fetchAll() ?: []; + + return $item; + } + + /** + * Add a diagnostic comment or timeline entry to an Item. + */ + public function addComment( + int $itemId, + string $author, + string $comment, + ?string $actionTaken = null, + ?array $meta = null + ): array { + $stmt = $this->db->prepare(' + INSERT INTO bugtracker_comments ( + item_id, author, comment, action_taken, meta_json, created_at + ) VALUES ( + :item_id, :author, :comment, :action, :meta, NOW() + ) + '); + $stmt->execute([ + ':item_id' => $itemId, + ':author' => $author, + ':comment' => $comment, + ':action' => $actionTaken, + ':meta' => !empty($meta) ? json_encode($meta) : null, + ]); + + return [ + 'id' => (int)$this->db->lastInsertId(), + 'item_id' => $itemId, + 'author' => $author, + 'created_at' => date('Y-m-d H:i:s'), + ]; + } + + /** + * Update Item status. + */ + public function updateStatus(int $itemId, string $status, ?string $notes = null, string $author = 'agent'): bool + { + $allowed = ['open', 'planned', 'in_progress', 'resolved', 'closed', 'rejected']; + if (!in_array($status, $allowed)) { + return false; + } + + $stmt = $this->db->prepare('UPDATE bugtracker_items SET status = :status WHERE id = :id'); + $result = $stmt->execute([':status' => $status, ':id' => $itemId]); + + if ($result) { + $msg = 'Status geändert auf "' . $status . '"' . ($notes ? ': ' . $notes : ''); + $this->addComment($itemId, $author, $msg, 'status_changed'); + } + + return $result; + } + + /** + * Resolve a Bug or complete a Feature Request with build details. + */ + public function resolveItem( + int $itemId, + string $resolvedInBuild, + ?string $resolutionNotes = null, + string $author = 'agent' + ): bool { + $stmt = $this->db->prepare(' + UPDATE bugtracker_items + SET status = "resolved", + resolved_in_build = :build, + resolution_notes = :notes, + resolved_at = NOW() + WHERE id = :id + '); + $result = $stmt->execute([ + ':build' => $resolvedInBuild, + ':notes' => $resolutionNotes, + ':id' => $itemId, + ]); + + if ($result) { + $msg = 'Als gelöst/umgesetzt markiert in Build "' . $resolvedInBuild . '"' . ($resolutionNotes ? '. Note: ' . $resolutionNotes : ''); + $this->addComment($itemId, $author, $msg, 'marked_resolved'); + } + + return $result; + } + + /** + * Get summary stats for the dashboard. + */ + public function getStats(): array + { + $stats = [ + 'open_bugs_prod' => 0, + 'open_bugs_dev' => 0, + 'open_features' => 0, + 'resolved_total' => 0, + 'critical_bugs' => 0, + ]; + + $res = $this->db->query(' + SELECT + SUM(CASE WHEN type = "bug" AND environment = "production" AND status IN ("open", "in_progress") THEN 1 ELSE 0 END) as open_bugs_prod, + SUM(CASE WHEN type = "bug" AND environment = "development" AND status IN ("open", "in_progress") THEN 1 ELSE 0 END) as open_bugs_dev, + SUM(CASE WHEN type = "feature_request" AND status IN ("open", "planned", "in_progress") THEN 1 ELSE 0 END) as open_features, + SUM(CASE WHEN status = "resolved" THEN 1 ELSE 0 END) as resolved_total, + SUM(CASE WHEN type = "bug" AND severity = "critical" AND status IN ("open", "in_progress") THEN 1 ELSE 0 END) as critical_bugs + FROM bugtracker_items + ')->fetch(); + + if ($res) { + $stats['open_bugs_prod'] = (int)($res['open_bugs_prod'] ?? 0); + $stats['open_bugs_dev'] = (int)($res['open_bugs_dev'] ?? 0); + $stats['open_features'] = (int)($res['open_features'] ?? 0); + $stats['resolved_total'] = (int)($res['resolved_total'] ?? 0); + $stats['critical_bugs'] = (int)($res['critical_bugs'] ?? 0); + } + + return $stats; + } +} diff --git a/src/Modules/UpdateService/UpdateManager.php b/src/Modules/UpdateService/UpdateManager.php index cbd6c37..c8879d1 100644 --- a/src/Modules/UpdateService/UpdateManager.php +++ b/src/Modules/UpdateService/UpdateManager.php @@ -13,14 +13,14 @@ class UpdateManager $this->db = $db; } - public function checkUpdate(string $productSlug, string $currentVersion): ?array + public function checkUpdate(string $productSlug, string $currentVersion, string $channel = 'prod'): ?array { $stmt = $this->db->prepare(' SELECT * FROM updateservice_releases - WHERE product_slug = :slug AND version > :ver + WHERE product_slug = :slug AND channel = :channel AND version > :ver ORDER BY created_at DESC LIMIT 1 '); - $stmt->execute([':slug' => $productSlug, ':ver' => $currentVersion]); + $stmt->execute([':slug' => $productSlug, ':channel' => $channel, ':ver' => $currentVersion]); $latest = $stmt->fetch(); return $latest ?: null; @@ -29,38 +29,55 @@ class UpdateManager public function addRelease( string $productSlug, string $version, - ?string $releaseNotes, - string $downloadUrl, - ?string $sha256Hash, + string $channel = 'prod', + ?string $releaseNotes = null, + string $downloadUrl = '', + ?string $sha256Hash = null, + ?string $gitCommit = null, + int $sizeBytes = 0, + ?string $manifestJson = null, bool $isCritical = false ): bool { $stmt = $this->db->prepare(' INSERT INTO updateservice_releases ( - product_slug, version, release_notes, download_url, sha256_hash, is_critical + product_slug, version, channel, release_notes, download_url, sha256_hash, git_commit, size_bytes, manifest_json, is_critical ) VALUES ( - :slug, :version, :notes, :url, :hash, :critical + :slug, :version, :channel, :notes, :url, :hash, :git, :size, :manifest, :critical ) ON DUPLICATE KEY UPDATE release_notes = VALUES(release_notes), download_url = VALUES(download_url), sha256_hash = VALUES(sha256_hash), + git_commit = VALUES(git_commit), + size_bytes = VALUES(size_bytes), + manifest_json = VALUES(manifest_json), is_critical = VALUES(is_critical) '); return $stmt->execute([ ':slug' => $productSlug, ':version' => $version, + ':channel' => $channel, ':notes' => $releaseNotes, ':url' => $downloadUrl, ':hash' => $sha256Hash, + ':git' => $gitCommit, + ':size' => $sizeBytes, + ':manifest' => $manifestJson, ':critical' => $isCritical ? 1 : 0, ]); } - public function getReleases(?string $productSlug = null): array + public function getReleases(?string $productSlug = null, ?string $channel = null): array { - if ($productSlug) { + if ($productSlug && $channel) { + $stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE product_slug = :slug AND channel = :channel ORDER BY created_at DESC'); + $stmt->execute([':slug' => $productSlug, ':channel' => $channel]); + } elseif ($productSlug) { $stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE product_slug = :slug ORDER BY created_at DESC'); $stmt->execute([':slug' => $productSlug]); + } elseif ($channel) { + $stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE channel = :channel ORDER BY created_at DESC'); + $stmt->execute([':channel' => $channel]); } else { $stmt = $this->db->query('SELECT * FROM updateservice_releases ORDER BY created_at DESC'); }