Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 56 additions & 17 deletions src/PlugHub.Framework/Packages/RepositoryArchiveSynchronizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ public bool Sync(PackageRepositoryConfiguration repository, string cacheDirector

try
{
var archiveUrl = WithCacheBust(ArchiveUrl(address, repository));
DownloadArchive(archiveUrl, repository, archivePath);
var archiveUrl = ArchiveDownloadUrl(address, repository);
DownloadArchive(archiveUrl, address, repository, archivePath);
ValidateArchiveFile(archivePath, archiveUrl);
ExtractArchive(archivePath, stagingDirectory);
}
Expand Down Expand Up @@ -88,23 +88,24 @@ public bool Sync(PackageRepositoryConfiguration repository, string cacheDirector
private bool ShouldUseGiteeApiFallback(RepositoryAddress address, PackageRepositoryConfiguration repository, Exception exception)
{
if (!string.Equals(address.Provider, "gitee", StringComparison.OrdinalIgnoreCase)) return false;
if (!string.Equals(repository.Visibility, "private", StringComparison.OrdinalIgnoreCase)) return false;
if (string.IsNullOrWhiteSpace(_credentialService.ResolveApiKey(repository))) return false;
if (RepositoryRequiresToken(repository) && string.IsNullOrWhiteSpace(_credentialService.ResolveApiKey(repository))) return false;
if (exception is InvalidDataException) return true;

var webException = exception as WebException;
var response = webException?.Response as HttpWebResponse;
if (response == null) return false;

return response.StatusCode == HttpStatusCode.Forbidden
return response.StatusCode == HttpStatusCode.BadRequest
|| response.StatusCode == HttpStatusCode.Forbidden
|| response.StatusCode == HttpStatusCode.Unauthorized
|| response.StatusCode == HttpStatusCode.NotFound;
|| response.StatusCode == HttpStatusCode.NotFound
|| response.StatusCode == HttpStatusCode.MethodNotAllowed;
}

private void SyncGiteeRepositoryViaApi(RepositoryAddress address, PackageRepositoryConfiguration repository, string stagingDirectory)
{
var apiKey = _credentialService.ResolveApiKey(repository);
if (string.IsNullOrWhiteSpace(apiKey))
if (RepositoryRequiresToken(repository) && string.IsNullOrWhiteSpace(apiKey))
{
throw new InvalidOperationException("Private Gitee repository requires an access token.");
}
Expand Down Expand Up @@ -188,10 +189,15 @@ private Dictionary<string, object> ReadJsonObject(Uri uri)

private static Uri GiteeApiUrl(RepositoryAddress address, string apiPath, string apiKey, string extraQuery)
{
var query = "access_token=" + Uri.EscapeDataString(apiKey.Trim());
var queryParts = new List<string>();
if (!string.IsNullOrWhiteSpace(apiKey))
{
queryParts.Add("access_token=" + Uri.EscapeDataString(apiKey.Trim()));
}

if (!string.IsNullOrWhiteSpace(extraQuery))
{
query += "&" + extraQuery.TrimStart('&', '?');
queryParts.Add(extraQuery.TrimStart('&', '?'));
}

return new Uri("https://gitee.com/api/v5/repos/"
Expand All @@ -200,8 +206,7 @@ private static Uri GiteeApiUrl(RepositoryAddress address, string apiPath, string
+ Uri.EscapeDataString(address.Name)
+ "/"
+ apiPath
+ "?"
+ query);
+ (queryParts.Count == 0 ? string.Empty : "?" + string.Join("&", queryParts)));
}

private static string EscapePath(string path)
Expand All @@ -223,6 +228,17 @@ private static string StringValue(Dictionary<string, object> source, string key)
return Convert.ToString(value) ?? string.Empty;
}

private Uri ArchiveDownloadUrl(RepositoryAddress address, PackageRepositoryConfiguration repository)
{
var archiveUrl = ArchiveUrl(address, repository);
return ShouldAppendArchiveCacheBust(address) ? WithCacheBust(archiveUrl) : archiveUrl;
}

private static bool ShouldAppendArchiveCacheBust(RepositoryAddress address)
{
return string.Equals(address.Provider, "github", StringComparison.OrdinalIgnoreCase);
}

private Uri ArchiveUrl(RepositoryAddress address, PackageRepositoryConfiguration repository)
{
var gitRef = string.IsNullOrWhiteSpace(repository.Ref) ? "main" : repository.Ref.Trim();
Expand All @@ -246,7 +262,7 @@ private Uri ArchiveUrl(RepositoryAddress address, PackageRepositoryConfiguration
return new Uri(url);
}

private void DownloadArchive(Uri archiveUrl, PackageRepositoryConfiguration repository, string archivePath)
private void DownloadArchive(Uri archiveUrl, RepositoryAddress address, PackageRepositoryConfiguration repository, string archivePath)
{
var request = (HttpWebRequest)WebRequest.Create(archiveUrl);
request.Method = "GET";
Expand All @@ -257,8 +273,8 @@ private void DownloadArchive(Uri archiveUrl, PackageRepositoryConfiguration repo
request.UserAgent = ArchiveDownloadUserAgent;

var apiKey = _credentialService.ResolveApiKey(repository);
if (string.Equals(repository.Provider, "github", StringComparison.OrdinalIgnoreCase)
&& string.Equals(repository.Visibility, "private", StringComparison.OrdinalIgnoreCase)
if (string.Equals(address.Provider, "github", StringComparison.OrdinalIgnoreCase)
&& RepositoryRequiresToken(repository)
&& !string.IsNullOrWhiteSpace(apiKey))
{
request.Headers["Authorization"] = "Bearer " + apiKey.Trim();
Expand Down Expand Up @@ -286,6 +302,11 @@ private static Uri WithCacheBust(Uri uri)
return new Uri(uri + separator + "plughubCacheBust=" + DateTime.UtcNow.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture));
}

private static bool RepositoryRequiresToken(PackageRepositoryConfiguration repository)
{
return string.Equals(repository.Visibility, "private", StringComparison.OrdinalIgnoreCase);
}

private static void ExtractArchive(string archivePath, string targetDirectory)
{
using (var archive = ZipFile.OpenRead(archivePath))
Expand Down Expand Up @@ -480,8 +501,8 @@ private RepositoryAddress(string provider, string owner, string name)
return null;
}

var expectedHost = string.Equals(provider, "gitee", StringComparison.OrdinalIgnoreCase) ? "gitee.com" : "github.com";
if (!string.Equals(uri.Host, expectedHost, StringComparison.OrdinalIgnoreCase))
var hostProvider = ProviderFromHost(uri.Host);
if (string.IsNullOrWhiteSpace(hostProvider))
{
return null;
}
Expand All @@ -490,7 +511,7 @@ private RepositoryAddress(string provider, string owner, string name)
.Trim('/')
.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
return segments.Length >= 2
? new RepositoryAddress(provider, segments[0], StripRepositorySuffix(segments[1]))
? new RepositoryAddress(hostProvider, segments[0], StripRepositorySuffix(segments[1]))
: null;
}

Expand All @@ -508,6 +529,24 @@ private static string StripRepositorySuffix(string value)
: value;
}

private static string ProviderFromHost(string host)
{
var normalized = (host ?? string.Empty).Trim().TrimEnd('.');
if (string.Equals(normalized, "github.com", StringComparison.OrdinalIgnoreCase)
|| string.Equals(normalized, "www.github.com", StringComparison.OrdinalIgnoreCase))
{
return "github";
}

if (string.Equals(normalized, "gitee.com", StringComparison.OrdinalIgnoreCase)
|| string.Equals(normalized, "www.gitee.com", StringComparison.OrdinalIgnoreCase))
{
return "gitee";
}

return string.Empty;
}

private static string StripUrlUserInfo(string url)
{
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.UserInfo))
Expand Down
7 changes: 6 additions & 1 deletion src/PlugHub.StaticValidation/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1909,6 +1909,9 @@ private static void ValidateRevitWpfUiDesignSpecification()
Require(theme.Contains("class RevitUiPalette") && theme.Contains("class RevitUiTheme"), "Revit WPF UI must centralize theme tokens in RevitUiTheme.");
Require(theme.Contains("UIThemeManager") && theme.Contains("AppsUseLightTheme"), "Revit WPF UI theme detection must prefer Revit host theme and fall back to Windows app theme.");
Require(theme.Contains("ButtonStyle") && theme.Contains("TabItem") && theme.Contains("DataGridRow"), "Revit WPF UI theme must provide shared styles for buttons, tabs, and grids.");
Require(theme.Contains("resources.Add(typeof(ComboBoxItem), ComboBoxItemStyle(palette))") && theme.Contains("ComboBoxItemTemplate"), "Revit WPF UI theme must explicitly style ComboBox dropdown items instead of leaving selected items on system colors.");
Require(theme.Contains("ComboBoxItem.IsHighlightedProperty") && theme.Contains("Selector.IsSelectedProperty") && theme.Contains("Control.BackgroundProperty, palette.SelectionBrush") && theme.Contains("Control.ForegroundProperty, palette.TextBrush"), "ComboBox dropdown hover and selected states must keep readable themed foreground/background colors.");
Require(theme.Contains("TabItemTemplate(palette)") && theme.Contains("ControlTemplate(typeof(TabItem))") && theme.Contains("RootBorder") && theme.Contains("Control.BorderBrushProperty, palette.AccentBrush"), "selected settings tabs must use an explicit template so WPF system colors cannot turn the selected tab white.");
Require(theme.Contains("MenuItemTemplate") && theme.Contains("PART_Popup") && theme.Contains("SubmenuArrow"), "context menus must use a compact MenuItem template without the default icon slot.");
Require(settingsWindow.Contains("RevitUiTheme.Apply(this)") && statusWindow.Contains("RevitUiTheme.Apply(this)"), "settings and status windows must share the Revit WPF theme.");
Require(settingsWindow.Contains("BuildAboutTab") && settingsWindow.Contains("tabs.Items.Add(BuildAboutTab())"), "settings window must include an About tab.");
Expand Down Expand Up @@ -2103,7 +2106,9 @@ private static void ValidatePackageSourceAndReleaseBehavior()
Require(repositoryBrowser.Contains("RepositoryArchiveSynchronizer") && repositoryBrowser.Contains("_archiveSynchronizer.Sync"), "repository browsing must delegate remote cache refresh to the HTTP archive synchronizer.");
Require(repositoryArchiveSynchronizer.Contains("HttpWebRequest") && repositoryArchiveSynchronizer.Contains("ZipFile.OpenRead") && repositoryArchiveSynchronizer.Contains("ExtractArchive"), "repository archive synchronizer must download and extract repository zip archives.");
Require(repositoryArchiveSynchronizer.Contains("ArchiveDownloadUserAgent") && repositoryArchiveSynchronizer.Contains("curl/8.0.1") && !repositoryArchiveSynchronizer.Contains("request.UserAgent = \"PlugHub\""), "repository archive downloads must use a Gitee-compatible user agent accepted by archive endpoints.");
Require(repositoryArchiveSynchronizer.Contains("WithCacheBust(ArchiveUrl(address, repository))") && repositoryArchiveSynchronizer.Contains("RequestCachePolicy(RequestCacheLevel.Reload)"), "repository source sync must bypass stale HTTP/archive cache before replacing the local repository cache.");
Require(repositoryArchiveSynchronizer.Contains("ProviderFromHost(uri.Host)") && repositoryArchiveSynchronizer.Contains("new RepositoryAddress(hostProvider"), "absolute repository URLs must infer GitHub or Gitee from the URL host instead of failing when the provider field is stale.");
Require(repositoryArchiveSynchronizer.Contains("ArchiveDownloadUrl(address, repository)") && repositoryArchiveSynchronizer.Contains("ShouldAppendArchiveCacheBust") && repositoryArchiveSynchronizer.Contains("RequestCachePolicy(RequestCacheLevel.Reload)"), "repository source sync must bypass stale GitHub HTTP/archive cache without adding unsupported cache-bust query parameters to Gitee archive URLs.");
Require(repositoryArchiveSynchronizer.Contains("HttpStatusCode.BadRequest") && repositoryArchiveSynchronizer.Contains("RepositoryRequiresToken(repository)") && repositoryArchiveSynchronizer.Contains("SyncGiteeRepositoryViaApi(address, repository, stagingDirectory)"), "public Gitee archive failures must fall back to the Gitee API file download path instead of surfacing a raw 400 response.");
Require(repositoryArchiveSynchronizer.Contains("ValidateArchiveFile") && repositoryArchiveSynchronizer.IndexOf("ValidateArchiveFile(archivePath, archiveUrl)", StringComparison.Ordinal) < repositoryArchiveSynchronizer.IndexOf("ExtractArchive(archivePath, stagingDirectory)", StringComparison.Ordinal), "repository archive synchronizer must validate downloaded zip content before extraction.");
Require(repositoryArchiveSynchronizer.Contains("Downloaded repository archive is not a zip file") && repositoryArchiveSynchronizer.Contains("Check repository URL, ref, and credentials"), "repository archive synchronizer must report a clear URL/ref diagnostic for non-zip responses.");
Require(repositoryArchiveSynchronizer.Contains("EnsureHttpsResponse(response.ResponseUri)") && repositoryArchiveSynchronizer.IndexOf("EnsureHttpsResponse(response.ResponseUri)", StringComparison.Ordinal) < repositoryArchiveSynchronizer.IndexOf("source.CopyTo(target)", StringComparison.Ordinal), "repository archive downloads must reject redirects away from HTTPS before writing archive bytes.");
Expand Down
Loading
Loading