using System.Net.Http.Json; using Amazon; using Amazon.S3; using Amazon.S3.Transfer; namespace ReleaseBuilder.Build; public static partial class Command { /// /// Implementation of the various upload functions /// static class Upload { /// /// The Github structure for post data /// /// The tag to create the release for /// The commit hash or name /// The name of the release /// The release message /// Flag toggling draft release /// Flag toggling the prerelease tag /// Flag togling release note generator private record GhReleaseInfo( string tag_name, string target_commitish, string name, string body, bool draft, bool prerelease, bool generate_release_notes ); /// /// The Github structure for response data /// /// The URL to the assets /// The URL to the release /// The release ID private record GhReleaseResponse( string assets_url, string url, int id ); /// /// Uploads a local file to a remote location /// /// The path to the file on the local filesystem /// The name of the file on the remote target public record UploadFile(string Path, string Name); /// /// Single entry in the installer json support file /// /// The remote package url /// The filename of the package /// The MD5 hash of the file /// The SHA256 hash of the file /// The size of the file private record PackageEntry(string url, string filename, string md5, string sha256, long size); /// /// Creates the package list in JSON format /// /// The packages to include in the file /// The runtime configuration /// The JSON string public static string CreatePackageJson(IEnumerable packages, RuntimeConfig rtcfg) { var entries = packages.Select(f => (f.Target.PackageTargetString, Entry: new PackageEntry( url: $"https://updates.duplicati.com/{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/{System.Web.HttpUtility.UrlEncode(Path.GetFileName(f.CreatedFile))}", filename: Path.GetFileName(f.CreatedFile), md5: CalculateHash(f.CreatedFile, "md5"), sha256: CalculateHash(f.CreatedFile, "sha256"), size: new FileInfo(f.CreatedFile).Length ))) .ToDictionary(x => x.PackageTargetString, x => x.Entry); return System.Text.Json.JsonSerializer.Serialize(entries, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); } /// /// Upload release files to S3 storage /// /// The files to upload /// The runtime configuration /// An awaitable task public static async Task UploadToS3(IEnumerable files, RuntimeConfig rtcfg) { var totalSize = files.Sum(f => new FileInfo(f.Path).Length); Console.WriteLine($"Uploading {files.Count()} files ({Duplicati.Library.Utility.Utility.FormatSizeString(totalSize)}) to S3..."); var chain = new Amazon.Runtime.CredentialManagement.CredentialProfileStoreChain(); if (!chain.TryGetAWSCredentials(Program.Configuration.ConfigFiles.AwsUploadProfile, out var awsCredentials)) throw new Exception($"The aws-cli profile '{Program.Configuration.ConfigFiles.AwsUploadProfile}' could not be found."); chain.TryGetProfile(Program.Configuration.ConfigFiles.AwsUploadProfile, out var awsProfile); var config = new AmazonS3Config { RegionEndpoint = awsProfile?.Region ?? RegionEndpoint.USEast1, UseArnRegion = false, ServiceURL = awsProfile?.EndpointUrl ?? "https://s3.amazonaws.com" }; using var client = new AmazonS3Client(awsCredentials, config); var fileTransferUtility = new TransferUtility(client); var size = 0L; foreach (var file in files) { await fileTransferUtility.UploadAsync( file.Path, Program.Configuration.ConfigFiles.AwsUploadBucket, $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/{file.Name}" ); var filesize = new FileInfo(file.Path).Length; size += filesize; Console.WriteLine($"{size / (double)totalSize * 100:F1}% - Uploaded {file.Name} ({Duplicati.Library.Utility.Utility.FormatSizeString(filesize)})"); } } /// /// Upload release files to Github /// /// The files to upload /// The runtime configuration /// An awaitable task public static async Task UploadToGithub(IEnumerable files, RuntimeConfig rtcfg) { using var httpClient = new HttpClient(); var totalSize = files.Sum(f => new FileInfo(f.Path).Length); Console.WriteLine($"Uploading {files.Count()} files ({Duplicati.Library.Utility.Utility.FormatSizeString(totalSize)}) to Github..."); var ghtoken = File.ReadAllText(Program.Configuration.ConfigFiles.GithubTokenFile).Trim(); var owner = "duplicati"; var repo = "duplicati"; var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.github.com/repos/{owner}/{repo}/releases"); request.Headers.Add("Accept", "application/vnd.github+json"); request.Headers.Add("Authorization", $"Bearer {ghtoken}"); request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); request.Headers.Add("User-Agent", "Duplicati Release Builder v1"); request.Content = JsonContent.Create(new GhReleaseInfo( tag_name: $"v{rtcfg.ReleaseInfo.ReleaseName}", target_commitish: "master", name: $"v{rtcfg.ReleaseInfo.ReleaseName}", body: rtcfg.ChangelogNews, draft: false, prerelease: rtcfg.ReleaseInfo.Channel != ReleaseChannel.Stable, generate_release_notes: false )); var response = await httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); var releasedata = await response.Content.ReadFromJsonAsync() ?? throw new Exception("Failed to read release data"); var size = 0L; // Upload the files to the same tag foreach (var file in files) { request = new HttpRequestMessage(HttpMethod.Post, $"https://uploads.github.com/repos/{owner}/{repo}/releases/{releasedata.id}/assets?name={System.Net.WebUtility.UrlEncode(Path.GetFileName(file.Name))}"); request.Headers.Add("Accept", "application/vnd.github+json"); request.Headers.Add("Authorization", $"Bearer {ghtoken}"); request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); request.Headers.Add("User-Agent", "Duplicati Release Builder v1"); using var fileStream = File.OpenRead(file.Path); request.Content = new StreamContent(fileStream); request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); response = await httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); var filesize = new FileInfo(file.Path).Length; size += filesize; Console.WriteLine($"{size / (double)totalSize * 100:F1}% - Uploaded {file.Name} ({Duplicati.Library.Utility.Utility.FormatSizeString(filesize)})"); } } /// /// Reload the update server cache /// /// The runtime configuration /// An awaitable task public static async Task ReloadUpdateServer(RuntimeConfig rtcfg) { using var client = new HttpClient(); var req = new HttpRequestMessage(HttpMethod.Post, "https://updates.duplicati.com/reload"); var reloadToken = Program.Configuration.ConfigFiles.ReloadUpdatesApiKey; req.Headers.Add("X-API-KEY", reloadToken); req.Content = JsonContent.Create(new[] { $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/latest-v2.json", $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/latest-v2.js", $"{rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}/latest-v2.manifest", }); var response = await client.SendAsync(req); response.EnsureSuccessStatusCode(); } /// /// Post the release to the Duplicati forum /// /// The runtime configuration /// An awaitable task public static async Task PostToForum(RuntimeConfig rtcfg) { using var client = new HttpClient(); var req = new HttpRequestMessage(HttpMethod.Post, "https://forum.duplicati.com/posts"); var discourseToken = File.ReadAllText(Program.Configuration.ConfigFiles.DiscourseTokenFile).Trim().Split(":", 2); req.Headers.Add("Api-Username", discourseToken[0]); req.Headers.Add("Api-Key", discourseToken[1]); req.Headers.Add("Accept", "application/json"); req.Content = new FormUrlEncodedContent([ new KeyValuePair("category", "10"), new KeyValuePair("title", $"Release: {rtcfg.ReleaseInfo.Version} ({rtcfg.ReleaseInfo.Channel}) {rtcfg.ReleaseInfo.Timestamp:yyyy-MM-dd}"), new KeyValuePair("raw", $"# [{rtcfg.ReleaseInfo.ReleaseName}](https://github.com/duplicati/duplicati/releases/tag/v{rtcfg.ReleaseInfo.ReleaseName})\n\n{rtcfg.ChangelogNews}") ]); var resp = await client.SendAsync(req); resp.EnsureSuccessStatusCode(); } } }