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();
}
}
}