Exemple #1
0
        public override async Task <int> ExecuteAsync()
        {
            var http   = HttpClientFactory.Create();
            var result = 0;

            if (Files.Count == 0)
            {
                return(0);
            }

            var length = Files.Select(x => x.Path).Max(x => x.Length) + 1;

            void Write(string s) => Console.Write(s + new string(' ', length - s.Length));

            var processed = new Dictionary <(string, Uri), object?>();

            // TODO: allow configuration to provide HTTP headers, i.e. auth?
            foreach (var file in Files)
            {
                var section = Configuration.GetSection("file", file.Path);

                if (section.GetBoolean("skip") == true)
                {
                    continue;
                }

                if (File.Exists(file.Path) && File.GetAttributes(file.Path).HasFlag(FileAttributes.ReadOnly))
                {
                    Write(file.Path);
                    Console.WriteLine("? Readonly, skipping");
                    continue;
                }

                var uri = file.Uri;
                if (uri == null)
                {
                    var url = section.GetString("url");
                    if (url != null)
                    {
                        uri = new Uri(url);
                    }
                    else
                    {
                        Write(file.Path);
                        Console.WriteLine("x Unconfigured");
                        continue;
                    }
                }

                if (processed.ContainsKey((file.Path, uri)))
                {
                    continue;
                }

                var etag        = file.ETag ?? section.GetString("etag");
                var weak        = section.GetBoolean("weak");
                var sha         = section.GetString("sha");
                var originalUri = uri;

                try
                {
                    processed.Add((file.Path, uri), null);
                    var request = new HttpRequestMessage(DryRun ? HttpMethod.Head : HttpMethod.Get, uri);
                    // Propagate previous values, used in GitHubRawHandler to optimize SHA retrieval
                    if (etag != null)
                    {
                        request.Headers.TryAddWithoutValidation("X-ETag", etag);
                    }
                    if (sha != null)
                    {
                        request.Headers.TryAddWithoutValidation("X-Sha", sha);
                    }

                    if (etag != null && File.Exists(file.Path))
                    {
                        // Try HEAD and skip file if same etag
                        var headReq = new HttpRequestMessage(HttpMethod.Head, uri);
                        // Propagate previous values, used in GitHubRawHandler to optimize SHA retrieval
                        if (etag != null)
                        {
                            headReq.Headers.TryAddWithoutValidation("X-ETag", etag);
                        }
                        if (sha != null)
                        {
                            headReq.Headers.TryAddWithoutValidation("X-Sha", sha);
                        }

                        var headResp = await http.SendAsync(headReq);

                        if (headResp.IsSuccessStatusCode &&
                            headResp.Headers.ETag?.Tag?.Trim('"') == etag)
                        {
                            // To keep "noise" from unchanged files to a minimum, when
                            // doing a dry run we only list actual changes.
                            if (!DryRun)
                            {
                                // No need to download
                                Write(file.Path);
                                ColorConsole.Write("=".DarkGray());
                                Console.WriteLine($" <- {originalUri}");

                                // For backs compat, set the sha if found and not already present.
                                if (sha == null &&
                                    headResp.Headers.TryGetValues("X-Sha", out var headShas) &&
                                    headShas.FirstOrDefault() is string headSha &&
                                    !string.IsNullOrEmpty(headSha))
                                {
                                    section.SetString("sha", headSha);
                                }
                            }

                            continue;
                        }

                        // NOTE: this code alone didn't work consistently:
                        // For some reason, GH would still give us the full response,
                        // even with a different etag from the previous request.
                        request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue("\"" + etag + "\"", weak.GetValueOrDefault()));
                    }

                    Write(file.Path);

                    var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);

                    if (response.Headers.TryGetValues("X-Sha", out var values) &&
                        values.FirstOrDefault() is string newSha &&
                        !string.IsNullOrEmpty(newSha))
                    {
                        file.NewSha = newSha;
                    }
                    else
                    {
                        file.NewSha = null;
                    }

                    if (response.StatusCode == HttpStatusCode.NotModified)
                    {
                        // No need to download
                        ColorConsole.Write("=".DarkGray());
                        Console.WriteLine($" <- {originalUri}");

                        // For backs compat, set the sha if found and not already present.
                        if (section.GetString("sha") == null &&
                            file.NewSha != null)
                        {
                            section.SetString("sha", file.NewSha);
                        }

                        continue;
                    }

                    if (!response.IsSuccessStatusCode)
                    {
                        if (uri.Host.Equals("github.com") && !GitHub.TryIsInstalled(out var output))
                        {
                            ColorConsole.WriteLine("=> ", "the GitHub CLI is required for this URL".Red());
                            ColorConsole.WriteLine(output.Yellow());
                            ColorConsole.WriteLine("See https://cli.github.com/manual/installation".Yellow());
                            System.Diagnostics.Process.Start(new ProcessStartInfo("https://cli.github.com/manual/installation")
                            {
                                UseShellExecute = true
                            });
                            return(-1);
                        }

                        // The URL might be a directory or repo branch top-level path. If so, we can use the GitHub cli to fetch all files.
                        if (uri.Host.Equals("github.com") &&
                            (response.StatusCode == HttpStatusCode.NotFound ||
                             // BadRequest from our conversion to raw URLs in GitHubRawHandler
                             response.StatusCode == HttpStatusCode.BadRequest))
                        {
                            var gh = GitHub.TryGetFiles(file, out var repoFiles);
                            switch (gh)
                            {
                            case GitHubResult.Success:
                                var targetDir = file.IsDefaultPath ? null : file.Path;
                                // Store the URL for later updates
                                if (!Configuration.GetAll("file", targetDir, "url").Any(entry => uri.ToString().Equals(entry.RawValue, StringComparison.OrdinalIgnoreCase)))
                                {
                                    Configuration.AddString("file", targetDir, "url", uri.ToString());
                                }

                                // Run again with the fetched files.
                                var command = Clone();
                                command.Files.AddRange(repoFiles);
                                Console.WriteLine();

                                // Track all files as already processed to skip duplicate processing from
                                // existing expanded list.
                                foreach (var repoFile in repoFiles)
                                {
                                    processed.Add((repoFile.Path, repoFile.Uri !), null);
                                }

                                result = await command.ExecuteAsync();

                                foreach (var change in command.Changes)
                                {
                                    Changes.Add(change);
                                }

                                continue;

                            case GitHubResult.Failure:
                                return(-1);

                            case GitHubResult.Skip:
                                break;

                            default:
                                break;
                            }
                        }

                        ColorConsole.WriteLine($"x <- {originalUri}".Yellow());

                        if (response.StatusCode != HttpStatusCode.NotFound ||
                            !OnRemoteUrlMissing(file))
                        {
                            // Only show as error if we haven't deleted the file as part of a
                            // sync operation, or if the error is not 404.
                            ColorConsole.WriteLine(new string(' ', length + 5), $"{(int)response.StatusCode}: {response.ReasonPhrase}".Red());
                        }

                        continue;
                    }

                    if (!DryRun)
                    {
                        etag = response.Headers.ETag?.Tag?.Trim('"');

                        var path = file.Path.IndexOf(Path.AltDirectorySeparatorChar) != -1
                            ? file.Path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)
                            : file.Path;
                        // Ensure target directory exists.
                        if (Path.GetDirectoryName(path)?.Length > 0)
                        {
                            Directory.CreateDirectory(Path.GetDirectoryName(path) !);
                        }

                        var tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
                        try
                        {
                            using var stream = File.Open(tempPath, FileMode.Create);
                            await response.Content.CopyToAsync(stream);
                        }
                        catch (Exception) // Delete temp file on error
                        {
                            File.Delete(tempPath);
                            throw;
                        }
#if NETCOREAPP2_1
                        if (File.Exists(path))
                        {
                            File.Delete(path);
                        }

                        File.Move(tempPath, path);
#else
                        File.Move(tempPath, path, overwrite: true);
#endif

                        section.SetString("url", originalUri.ToString());

                        if (file.NewSha != null)
                        {
                            section.SetString("sha", file.NewSha);
                        }
                        else
                        {
                            section.Unset("sha");
                        }

                        if (etag == null)
                        {
                            section.Unset("etag");
                        }
                        else
                        {
                            section.SetString("etag", etag);
                        }

                        if (response.Headers.ETag?.IsWeak == true)
                        {
                            section.SetBoolean("weak", true);
                        }
                        else
                        {
                            section.Unset("weak");
                        }
                    }

                    Changes.Add(file);

                    ColorConsole.Write("✓".Green());
                    Console.WriteLine($" <- {originalUri}");
                }
        protected override async Task <HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            if (request.RequestUri?.Host.Equals("github.com") != true)
            {
                return(await base.SendAsync(request, cancellationToken));
            }

            var parts = request.RequestUri.PathAndQuery.Split('/', StringSplitOptions.RemoveEmptyEntries);

            // Ensure we only process URIs that have at least org/repo
            if (parts.Length < 2)
            {
                return(await base.SendAsync(request, cancellationToken));
            }

            // https://github.com/kzu/dotnet-file/raw/master/README.md
            // https://github.com/kzu/dotnet-file/blob/master/README.md
            // =>
            // https://raw.githubusercontent.com/kzu/dotnet-file/master/README.md

            // NOTE: we WILL make a raw URL for top-level org/repo URLs too, causing a
            // BadRequest or NotFound which is REQUIRED for AddCommand to detect and
            // fallback to a gh CLI call, so DO NOT change that behavior here.

            request.RequestUri = new Uri(new Uri("https://raw.githubusercontent.com/"), string.Join('/',
                                                                                                    parts.Take(2).Concat(parts.Skip(3))));

            var response = await base.SendAsync(request, cancellationToken);

            var originalEtag = request.Headers.TryGetValues("X-ETag", out var etags) ? etags.FirstOrDefault() : null;
            var originalSha  = request.Headers.TryGetValues("X-Sha", out var shas) ? shas.FirstOrDefault() : null;

            var newEtag = response.Headers.ETag?.Tag?.Trim('"');
            // Some day we may get the X-Sha directly from the response, see https://support.github.com/ticket/personal/0/1035411
            var newSha = response.Headers.TryGetValues("X-Sha", out shas) ? shas.FirstOrDefault() : null;

            // Try to retrieve the commit for the entry
            if (newSha == null &&
                response.IsSuccessStatusCode &&
                // original ETag might be null, for example
                // but if they are the same (same content therefore), we only request the new
                // sha if there wasn't one already, as an optimization to avoid retrieving it
                // when we already have it persisted from a previous request
                (originalEtag != newEtag || originalSha == null) &&
                parts.Length > 2 &&
                GitHub.IsInstalled &&
                GitHub.TryApi($"repos/{parts[0]}/{parts[1]}/commits?per_page=1&path={string.Join('/', parts.Skip(4))}", out var json) &&
                json is JArray commits &&
                commits[0] is JObject obj &&
                obj.Property("sha") is JProperty prop &&
                prop != null &&
                prop.Value.Type == JTokenType.String)
            {
                newSha = prop.Value.ToObject <string>();
            }

            // Just propagate back what we had initially, as an optimization for HEAD and cases
            // where etags match.
            if (newSha == null)
            {
                newSha = originalSha;
            }

            if (newSha != null)
            {
                response.Headers.TryAddWithoutValidation("X-Sha", newSha);
            }

            return(response);
        }