From b6d99555656a765e9d5ae8d489096279d5c2e277 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Thu, 26 Sep 2024 00:07:48 -0300 Subject: [PATCH] Add support for considering oss authors/contribs as indirect sponsors This feature effectively makes anyone who has ever contributed to an active (and popular) open source nuget package, an indirect sponsor that doesn't need to select a sponsorship tier with the sponsorable. This works in combination with the https://github.com/devlooped/nuget repository which will keep a public dump of the stats we collect once a month (first saturday of each month) with all the active packages on nuget.org and their github mapping and repo contributors. We provide a lookup page so users can quickly determine eligibility too, if they don't wish to sponsor. We'll link to that page from our diagnostic URL. We only do this for github repositories at the moment. --- .netconfig | 5 - docs/_config.yml | 27 ++- docs/_layouts/default.html | 10 ++ docs/assets/js/oss.js | 73 ++++++++ docs/github/index.md | 11 ++ docs/github/oss.md | 45 +++++ docs/spec.md | 2 +- docs/spec/2.0.1.md | 165 ++++++++++++++++++ src/Commands/NuGetStatsCommand.cs | 9 +- src/Commands/SyncCommand.cs | 8 +- src/Core/GraphQueries.cs | 8 +- src/Core/Records.cs | 7 +- src/Core/SponsorLinkOptions.cs | 10 ++ src/Core/SponsorType.cs | 5 + src/Core/SponsorsManager.cs | 66 +++++-- src/Tests/SponsorManagerTests.cs | 45 ++++- src/Tests/SyncCommandTests.cs | 12 +- src/Tests/System/Threading/Tasks/AsyncLazy.cs | 95 ---------- src/Web/Program.cs | 19 ++ 19 files changed, 472 insertions(+), 150 deletions(-) create mode 100644 docs/assets/js/oss.js create mode 100644 docs/github/oss.md create mode 100644 docs/spec/2.0.1.md delete mode 100644 src/Tests/System/Threading/Tasks/AsyncLazy.cs diff --git a/.netconfig b/.netconfig index 715b5a8a..a2c1fca8 100644 --- a/.netconfig +++ b/.netconfig @@ -93,11 +93,6 @@ sha = 2e84192eea07ecc84d5c15fca6f48f3a7e29bb59 etag = 966d76b2bfff876a7805d794d43e7bfdee18fe8ae64b73979da23bb27bac21b7 weak -[file "src/Tests/System/Threading/Tasks/AsyncLazy.cs"] - url = https://github.com/devlooped/catbag/blob/main/System/Threading/Tasks/AsyncLazy.cs - sha = 9f3330f09713aa5f746047e3a50ee839147a5797 - etag = 73320600b7a18e0eb25cadc3d687c69dc79181b0458facf526666e150c634782 - weak [file "src/Tests/Microsoft/Extensions/DependencyInjection/AddAsyncLazyExtension.cs"] url = https://github.com/devlooped/catbag/blob/main/Microsoft/Extensions/DependencyInjection/AddAsyncLazyExtension.cs sha = 2f8a7d3dffc4409dbda61afb43326ab9d871c1ec diff --git a/docs/_config.yml b/docs/_config.yml index 1580d251..b9a72474 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,6 +1,6 @@ # add cert and key for ssl -ssl_cert: localhost.crt -ssl_key: localhost.key +#ssl_cert: localhost.crt +#ssl_key: localhost.key baseurl: "/SponsorLink" permalink: pretty @@ -111,4 +111,25 @@ issues_template: | {{/each}} - {{/if}} \ No newline at end of file + {{/if}} + +oss_template: | +

You contributed to the following repositories:

+ + + + + + {{#each repositories}} + + + + + {{/each}} +
RepositoryPackages (downloads/day)
{{repo}} +
    + {{#each packages}} +
  • {{id}} ({{format downloads}})
  • + {{/each}} +
+
\ No newline at end of file diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index e51a0144..30c7a57b 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -6,6 +6,16 @@ {% include head.html %} + Skip to main content {% include icons/icons.html %} diff --git a/docs/assets/js/oss.js b/docs/assets/js/oss.js new file mode 100644 index 00000000..29517dd5 --- /dev/null +++ b/docs/assets/js/oss.js @@ -0,0 +1,73 @@ +Handlebars.registerHelper('format', function(number) { + return new Intl.NumberFormat().format(number); + }); +var template = Handlebars.compile(window.site.oss_template); + +var data = { + authors: { }, + repositories: { }, + packages: { } +}; + +fetch('https://raw.githubusercontent.com/devlooped/nuget/refs/heads/main/nuget.json') + .then(response => { + // Check if the response is successful + if (!response.ok) { + setError(`Failed to retrieve OSS data: ${response.status}`); + throw new Error('Failed to retrieve OSS data'); + } + return response.json(); + }) + .then(json => { + data = json; + setBusy(false); + }); + +async function lookupAccount() { + setBusy(true); + setError(''); + document.getElementById('data').innerHTML = ''; + var account = document.getElementById('account').value; + console.log('Looking up account: ' + account); + + if (account === '') { + setError('Please enter your github account.'); + setBusy(false); + return; + } + + if (data.authors[account] === undefined) { + document.getElementById('unsupported').style.display = ''; + document.getElementById('supported').style.display = 'none'; + } else { + const model = { + repositories: data.authors[account].sort().map(repo => ({ + repo: repo, + packages: Object.entries(data.packages[repo]) + .map(([id, downloads]) => ({ + id: id, + downloads: downloads + })) + .sort((a, b) => a.id.localeCompare(b.id)) + })) + }; + document.getElementById('data').innerHTML = template(model); + document.getElementById('unsupported').style.display = 'none'; + document.getElementById('supported').style.display = ''; + } + + setBusy(false); +} + +function setError(message) { + document.getElementById('error').innerHTML = message; + if (message !== '') { + document.getElementById('error').classList.add('warning'); + } else { + document.getElementById('error').classList.remove('warning'); + } +} + +function setBusy(busy) { + document.getElementById('spinner').style.display = busy ? '' : 'none'; +} \ No newline at end of file diff --git a/docs/github/index.md b/docs/github/index.md index f68ff2a1..460b3a37 100644 --- a/docs/github/index.md +++ b/docs/github/index.md @@ -273,6 +273,17 @@ The backend's authentication and configuration can be tested manually by navigat which would redirect to the GitHub OAuth app for authentication and upon returning to your issuer site, return the user's profile and claims as JSON. +Optional backend app settings (all prefixed with `SponsorLink:`): + +| Setting | Description | Default | +|---------|-------------|---------| +| ManifestBranch | The branch to look for the sponsorable manifest | Default branch retrieved from GitHub API | +| ManifestExpiration | The maximum timespan to cache the sponsorable manifest | `01:00:00` | +| BadgeExpiration | The maximum timespan to cache the usage badge | `00:05:00` | +| LogAnalytics | The Azure Log Analytics workspace ID to produce usage badges | | +| NoContributors | Do not consider code contributors to sponsorable repositories as sponsors | `false` | +| NoOpenSource | Do not consider open source authors to active nuget packages as sponsors | `false` | + ## Conclusion By requiring a GitHub OAuth app for each the sponsorable, the reference implementation avoids having a central diff --git a/docs/github/oss.md b/docs/github/oss.md new file mode 100644 index 00000000..5c676f4d --- /dev/null +++ b/docs/github/oss.md @@ -0,0 +1,45 @@ +--- +title: OSS Authors +parent: GitHub Sponsors +page_toc: false +--- + +
+ +# OSS Authors + +[Devlooped](https://devlooped.com) is a proud supporter of open-source software and its +authors. Therefore, we consider indirect sponsors any and all contributors to active +nuget packages that are open-source and have a GitHub repository. + +> An active nuget package has at least 200 downloads per day in aggregate across the last 5 versions. + +This page allows you to check your eligibility for indirect sponsorship as an OSS author/contributor. + +
+ + + + + + + + +
GitHub account: + + +
+ +
+
+
+ +

+ +

+ + \ No newline at end of file diff --git a/docs/spec.md b/docs/spec.md index 7771ee36..c4d6a0ec 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -3,7 +3,7 @@ title: Manifest Spec nav_order: 2 has_children: true has_toc: false -current: 2.0.0 +current: 2.0.1 --- {%- assign versions = site[page.collection] diff --git a/docs/spec/2.0.1.md b/docs/spec/2.0.1.md new file mode 100644 index 00000000..af195026 --- /dev/null +++ b/docs/spec/2.0.1.md @@ -0,0 +1,165 @@ +--- +layout: default +title: 2.0.1 +parent: Manifest Spec +--- + +# SponsorLink Version 2.0.1 + +## Overview + +A sponsor manifest is a JWT (JSON Web Token) that encapsulates a user' sponsorship relationship +with another user or organization (the "sponsorable"). This manifest is issued and signed by the +sponsorable (or the sponsorship platform, if supported). There are no third-party intermediaries +between the sponsorable (or sponsorship platform) and the sponsor. + +The sponsor manifest can be utilized to enable features exclusive to sponsors or to suppress requests +for sponsorships by the author's code. + +During regular usage of a SponsorLink-enabled tool or library, the author might perform an offline +check to verify the user's sponsorship status before enabling a feature or suppressing build warnings. + +This check is performed by reading the manifest from a well-known location in the user's environment +and verifying its contents and signature. + +Users can subsequently request a manifest to the backend issuer service provided by the author so +that subsequent checks can succeed, as well as renew/sync the manifest if it has expired. + +## Purpose + +Establishing a standard method to represent and verify sponsorships in a secure, offline, and +privacy-conscious manner would be advantageous for both open-source software (OSS) authors and users. + +## Terminology + +The term "sponsor" refers to the user or organization that is providing financial support +to another user or organization. + +The term "sponsorable" refers to the user or organization that is receiving financial +support from another user or organization. + +## Sponsorable Manifest + +A sponsorable user or organization can make its support for SponsorLink known by providing +a manifest in JWT (JSON Web Token) format containing the following claims: + +| Claim | Description | +| ----------- | ----------- | +| `iss` | [Standard claim](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.1) containing the URL of the backend that issues sponsor manifests | +| `aud` | [Standard claim](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3) containing one or more URLs of the supported sponsoring platforms | +| `iat` | [Standard claim](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6) containing the time the manifest was issued at | +| `sub_jwk` | [Standard claim](https://openid.net/specs/openid-connect-core-1_0.html#SelfIssuedResponse) containing the public key (JWK) that can be used to check the signature of issued sponsor manifests | +| `schema` | Optional schema version of the manifest. Defaults to 2.0.1 | + +This manifest can be discovered automatically by tools that provide sponsor manifest synchronization +and verification. + +{: .note } +> By convention, issuers should provide an endpoint at `[iss]/jwt` that returns the sponsorable manifest. + +The following is an example of a sponsorable manifest: + +```json +{ + "iss": "https://sponsorlink.devlooped.com/", + "aud": "https://github.com/sponsors/devlooped", + "iat": 1696118400, + "sub_jwk": { + "e": "AQAB", + "kty": "RSA", + "n": "5inhv8Q..." + } +} +``` + +## Sponsor Manifest + +The sponsor manifest is used to verify the sponsor's sponsorship status. It's a signed JWT +that the sponsorable issuer provides to the sponsor, containing the following claims: + +| Claim | Description | +| ----------- | ----------- | +| `iss` | The token [issuer](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.1), matching the sponsorable manifest issuer claim | +| `aud` | The [audience](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3) URL(s) from the sponsorable manifest | +| `iat` | The [time the manifest was issued at](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6) | +| `sub` | The [subject](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.2) claim, which is the sponsor account (i.e. user GitHub login) | +| `roles` | The sponsoring [roles](https://www.rfc-editor.org/rfc/rfc9068.html#section-7.2.1.1) of the authenticated user (e.g. team, org, user, contrib, oss) | +| `email` | The sponsor's email(s) [standard claim](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) | +| `exp` | The token's [expiration date](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4) | +| `schema` | Optional schema version of the manifest. Defaults to 2.0.0 | + +{: .note } +> Tools can fetch the sponsorable manifest from `[iss]/jwt` for verification of the sponsor manifest signature. + +The [roles](https://www.rfc-editor.org/rfc/rfc9068.html#section-7.2.1.1) claim can be used to distinguish +between different types of sponsorships. + +* `user`: The sponsor is personally sponsoring. +* `org`: The user belongs to at least one organization that is sponsoring. +* `contrib`: The user is a contributor and is therefore considered a sponsor. +* `team`: The user is a member of the sponsorable organization or is the sponsorable user. +* `oss`: The user is a contributor to other active open-source projects. + +For example, given: + +- An organization `acme` that is sponsoring another organization `devlooped`. +- A user `alice` who is a member of the organization `acme`. +- `alice` requests from `devlooped` a sponsor manifest to access a feature. + +The issuer provided by `devlooped` would return a signed sponsor manifest token containing the following claims: + +```json +{ + "iss": "https://sponsorlink.devlooped.com", + "aud": "https://github.com/sponsors/devlooped", + "sub": "alice", + "email": [ + "alice@gmail.com", + "alice@acme.com" + ], + "roles": "org", + "exp": 1696118400, + "schema": "2.0.0" +} +``` + +### Token Signing + +The issuing backend provided by the sponsorable signs the sponsor JWT using a private key. The +corresponding public key (available publicly in the sponsorable manifest itself) can be used by +the author's libraries and tools to verify the manifest's signature and expiration date. + +## Manifest Usage + +Various sponsorship platforms can provide reference implementations of the backend service to issue +SponsorLink manifests using their platform's APIs. For example, GitHub, Open Collective, and Patreon +could all provide such services. Either for self-hosting by authors or as a managed service. + +A client-side tool would be provided for users to synchronize their SponsorLink manifest(s) with the +backend service. This tool would typically require the user to authenticate with the platform and +authorize the backend service to access their sponsorship information. This step would be explicit +and require the user's consent to sharing their sponsorship information. + +{: .important } +> By convention, sponsor manifests are stored at `~/.sponsorlink/[platform]/[sponsorable].jwt`. + +The author's code can check for the manifest presence and verify its contents and signature, which +could enable or disable features, suppress build warnings, or provide other benefits to sponsors. +The author can optionally perform a local-only offline check to ensure the user's email address +in the manifest matches the one in his local source repository configuration. + +The author may also choose to verify the token's expiration date, decide on a grace period, and +issue a notification if the manifest is expired. + +{: .note } +> See the [GitHub implementation](../github/index.md) of SponsorLink for more information. + +## Privacy Considerations + +* The method of user identification for manifest generation is entirely determined by the issuer. +* The method of user identification for manifest consumption is left to the discretion of the tool + or library author. + +Once a manifest is generated in a user-explicit manner and with their consent, and stored in their +local environment, it can be utilized by any tool or library to locally verify sponsorships without +any further network access. \ No newline at end of file diff --git a/src/Commands/NuGetStatsCommand.cs b/src/Commands/NuGetStatsCommand.cs index abf31078..78cf8413 100644 --- a/src/Commands/NuGetStatsCommand.cs +++ b/src/Commands/NuGetStatsCommand.cs @@ -5,7 +5,6 @@ using System.Net; using System.Text; using System.Text.Json; -using System.Xml; using System.Xml.Linq; using Devlooped.Web; using DotNetConfig; @@ -24,8 +23,6 @@ namespace Devlooped.Sponsors; [Description("Emits the nuget.json manifest with all contributors to active nuget packages")] public class NuGetStatsCommand(ICommandApp app, Config config, IGraphQueryClient graph, IHttpClientFactory httpFactory) : GitHubAsyncCommand(app, config) { - record Model(ConcurrentDictionary> Authors, ConcurrentDictionary> Repositories, ConcurrentDictionary> Packages); - // Maximum versions to consider from a package history for determining whether the package // is a popular with a minimum amount of downloads. const int MaxVersions = 5; @@ -88,11 +85,11 @@ public override async Task ExecuteAsync(CommandContext context, NuGetStatsS // gh api repos/dotnet/aspnetcore/contributors --paginate | jq '[.[] | .login]' // The resulting model we'll populate. - Model model; + OpenSource model; if (File.Exists("nuget.json") && settings.Force != true) - model = JsonSerializer.Deserialize(File.ReadAllText("nuget.json"), JsonOptions.Default) ?? new Model([], [], []); + model = JsonSerializer.Deserialize(File.ReadAllText("nuget.json"), JsonOptions.Default) ?? new OpenSource([], [], []); else - model = new Model([], [], []); + model = new OpenSource([], [], []); using var http = httpFactory.CreateClient(); diff --git a/src/Commands/SyncCommand.cs b/src/Commands/SyncCommand.cs index 224b05b6..f44aa093 100644 --- a/src/Commands/SyncCommand.cs +++ b/src/Commands/SyncCommand.cs @@ -206,9 +206,7 @@ await Status().StartAsync(Sync.QueryingUserOrgSponsorships, async ctx => if (status == SponsorableManifest.Status.NotFound) { // We can directly query via the default HTTP client since the .github repository must be public. - branch = await httpFactory.GetQueryClient().QueryAsync(new GraphQuery( - $"/repos/{account}/.github", ".default_branch") - { IsLegacy = true }); + branch = await httpFactory.GetQueryClient().QueryAsync(GraphQueries.DefaultCommunityBranch(account)); if (branch != null && branch != "main") // Retry discovery with non-'main' branch @@ -258,9 +256,7 @@ await Status().StartAsync(Sync.FetchingManifests(sponsorables.Count), async ctx if (status == SponsorableManifest.Status.NotFound) { // We can directly query via the default HTTP client since the .github repository must be public. - branch = await httpFactory.GetQueryClient().QueryAsync(new GraphQuery( - $"/repos/{sponsorable}/.github", ".default_branch") - { IsLegacy = true }); + branch = await httpFactory.GetQueryClient().QueryAsync(GraphQueries.DefaultCommunityBranch(sponsorable)); if (branch != null && branch != "main") // Retry discovery with non-'main' branch diff --git a/src/Core/GraphQueries.cs b/src/Core/GraphQueries.cs index 96672c09..b8ba5e99 100644 --- a/src/Core/GraphQueries.cs +++ b/src/Core/GraphQueries.cs @@ -1,4 +1,5 @@ using System.Security.Principal; +using Octokit; using Scriban; namespace Devlooped.Sponsors; @@ -242,7 +243,7 @@ ... on User { /// /// Returns the unique repository owners of all repositories the user has contributed - /// commits to. + /// commits to (*recently*, from the docs at https://docs.github.com/en/graphql/reference/objects) /// internal static GraphQuery CoreViewerContributedRepoOwners(int pageSize = 100) => new( """ @@ -591,6 +592,11 @@ ... on User { } }; + /// + /// Gets the .github repo default branch, if it exists. + /// + public static GraphQuery DefaultCommunityBranch(string owner) => new($"/repos/{owner}/.github", ".default_branch") { IsLegacy = true }; + /// /// Gets a repository's default branch, if it exists. /// diff --git a/src/Core/Records.cs b/src/Core/Records.cs index 47045fd9..5b9cb0ac 100644 --- a/src/Core/Records.cs +++ b/src/Core/Records.cs @@ -1,4 +1,5 @@ -using System.ComponentModel; +using System.Collections.Concurrent; +using System.ComponentModel; namespace Devlooped.Sponsors; @@ -32,4 +33,6 @@ public record Tier(string Id, string Name, string Description, int Amount, bool public record OwnerRepo(string Owner, string Repo); -public record FundedRepository(string OwnerRepo, string[] Sponsorables); \ No newline at end of file +public record FundedRepository(string OwnerRepo, string[] Sponsorables); + +public record OpenSource(ConcurrentDictionary> Authors, ConcurrentDictionary> Repositories, ConcurrentDictionary> Packages); diff --git a/src/Core/SponsorLinkOptions.cs b/src/Core/SponsorLinkOptions.cs index e4b41ea6..a07f5474 100644 --- a/src/Core/SponsorLinkOptions.cs +++ b/src/Core/SponsorLinkOptions.cs @@ -42,4 +42,14 @@ public class SponsorLinkOptions /// Example badge usage: https://img.shields.io/endpoint?color=blue&url=https://sponsorlink.devlooped.com/badge?user /// public string? LogAnalytics { get; init; } + + /// + /// Do not consider contributors to sponsorable repositories as sponsors. + /// + public bool NoContributors { get; init; } + + /// + /// Do not consider authors that contribute to other open-source projects as indirect sponsors. + /// + public bool NoOpenSource { get; init; } } diff --git a/src/Core/SponsorType.cs b/src/Core/SponsorType.cs index 1e337084..9df2f96a 100644 --- a/src/Core/SponsorType.cs +++ b/src/Core/SponsorType.cs @@ -28,4 +28,9 @@ public enum SponsorTypes /// The user is the sponsorable account or member of the organization. /// Team = 8, + /// + /// The user is considered a sponsor because he is a contributor + /// to an active open-source project. + /// + OpenSource, } diff --git a/src/Core/SponsorsManager.cs b/src/Core/SponsorsManager.cs index 100294d5..75803eb1 100644 --- a/src/Core/SponsorsManager.cs +++ b/src/Core/SponsorsManager.cs @@ -2,7 +2,6 @@ using System.Security.Claims; using System.Text.Json; using System.Text.RegularExpressions; -using Azure.Data.Tables; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -13,7 +12,7 @@ namespace Devlooped.Sponsors; public partial class SponsorsManager(IOptions options, IHttpClientFactory httpFactory, IGraphQueryClientFactory graphFactory, - IMemoryCache cache, ILogger logger) + IMemoryCache cache, AsyncLazy oss, ILogger logger) { internal const string JwtCacheKey = nameof(SponsorsManager) + ".JWT"; internal const string ManifestCacheKey = nameof(SponsorsManager) + ".Manifest"; @@ -198,8 +197,9 @@ public async Task GetSponsorTypeAsync(ClaimsPrincipal? principal = logger.Assert(sponsoring is not null); // User is checked for auth on first line above - if (principal.FindFirst("urn:github:login") is { Value.Length: > 0 } claim && - sponsoring.Contains(claim.Value)) + var user = principal.FindFirst("urn:github:login")!.Value; + + if (sponsoring.Contains(user)) { // the user is directly sponsoring type |= SponsorTypes.User; @@ -215,14 +215,26 @@ public async Task GetSponsorTypeAsync(ClaimsPrincipal? principal = } // Next we check for direct contributions too. - // TODO: should this be configurable? - var contribs = await sponsor.QueryAsync(GraphQueries.ViewerContributedRepoOwners); - if (contribs is not null && - contribs.Contains(manifest.Sponsorable)) + if (options.NoContributors != true) { - type |= SponsorTypes.Contributor; + var contribs = await sponsor.QueryAsync(GraphQueries.ViewerContributedRepoOwners); + if (contribs is not null && + contribs.Contains(manifest.Sponsorable)) + { + type |= SponsorTypes.Contributor; + } } + // The OSS graph contains all contributors to active nuget packages that are open source. + // See NuGetStatsCommand.cs + if (options.NoOpenSource != true && await oss is { } graph && graph.Authors.ContainsKey(user)) + type |= SponsorTypes.OpenSource; + + // Determining if a user is an indirect sponsor via org emails is expensive, so if we already + // have a sponsor type, we return early. + if (type != SponsorTypes.None) + return type; + // Add verified org email(s) > user's emails check (even if user's email is not public // and the logged in account does not belong to the org). This covers the scenario where a // user has multiple GH accounts, one for each org he works for (i.e. a consultant), and a @@ -230,11 +242,6 @@ public async Task GetSponsorTypeAsync(ClaimsPrincipal? principal = // client's orgs, but he could still add his work emails to his personal account, keep them // private and verified, and then use them to access and be considered an org sponsor. - // Only do this if we couldn't already determine if the user is a sponsor (directly or indirectly), - // since it's expensive. - if (type != SponsorTypes.None) - return type; - if (!cache.TryGetValue(typeof(Organization[]), out var sponsoringOrgs) || sponsoringOrgs is null) { sponsoringOrgs = account.Type == AccountType.User ? @@ -326,6 +333,8 @@ await sponsorable.QueryAsync(GraphQueries.SponsoringOrganizationsForUser(account claims.Add(new("roles", "user")); if (sponsor.HasFlag(SponsorTypes.Contributor)) claims.Add(new("roles", "contrib")); + if (sponsor.HasFlag(SponsorTypes.OpenSource)) + claims.Add(new("roles", "oss")); // Use shorthand JWT claim for emails. See https://www.iana.org/assignments/jwt/jwt.xhtml claims.AddRange(principal.Claims.Where(x => x.Type == ClaimTypes.Email).Select(x => new Claim(JwtRegisteredClaimNames.Email, x.Value))); @@ -372,20 +381,39 @@ await sponsorable.QueryAsync(GraphQueries.SponsoringOrganizationsForUser(account } // Lookup for indirect sponsors, first via repo contributions. - var contribs = await graph.QueryAsync(GraphQueries.UserContributions(login)); - if (contribs is not null && contribs.Contains(sponsorable.Login)) + if (options.NoContributors != true) + { + var contribs = await graph.QueryAsync(GraphQueries.UserContributions(login)); + if (contribs is not null && contribs.Contains(sponsorable.Login)) + { + return new Sponsor(login, account?.Type ?? AccountType.User, new Tier("contrib", "Contributor", "Contributor", 0, false) + { + Meta = + { + ["tier"] = "contrib", + ["label"] = "sponsor 💚", + ["color"] = "#BFFFD3" + } + }) + { + Kind = SponsorTypes.Contributor, + }; + } + } + + if (options.NoOpenSource != true && await oss is { } data && data.Authors.ContainsKey(login)) { - return new Sponsor(login, account?.Type ?? AccountType.User, new Tier("contrib", "Contributor", "Contributor", 0, false) + return new Sponsor(login, account?.Type ?? AccountType.User, new Tier("oss", "Open Source", "Open Source", 0, false) { Meta = { - ["tier"] = "contrib", + ["tier"] = "oss", ["label"] = "sponsor 💚", ["color"] = "#BFFFD3" } }) { - Kind = SponsorTypes.Contributor, + Kind = SponsorTypes.OpenSource, }; } diff --git a/src/Tests/SponsorManagerTests.cs b/src/Tests/SponsorManagerTests.cs index 85d94a88..ee17658e 100644 --- a/src/Tests/SponsorManagerTests.cs +++ b/src/Tests/SponsorManagerTests.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Security.Claims; +using System.Text.Json; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -17,6 +18,23 @@ public sealed class SponsorManagerTests : IDisposable IGraphQueryClientFactory clientFactory; IConfiguration configuration; AsyncLazy principal; + static AsyncLazy oss; + + static SponsorManagerTests() + { + oss = new AsyncLazy(async () => + { + using var http = new HttpClient(); + var response = await http.GetAsync("https://raw.githubusercontent.com/devlooped/nuget/refs/heads/main/nuget.json"); + + Assert.True(response.IsSuccessStatusCode); + + var data = await JsonSerializer.DeserializeAsync(response.Content.ReadAsStream(), JsonOptions.Default); + Assert.NotNull(data); + + return data; + }); + } public SponsorManagerTests() { @@ -101,7 +119,7 @@ public async Task AnonymousUserIsNoSponsor() services.GetRequiredService>(), httpFactory, services.GetRequiredService(), - services.GetRequiredService(), + services.GetRequiredService(), oss, Mock.Of>()); Assert.Equal(SponsorTypes.None, await manager.GetSponsorTypeAsync()); @@ -130,7 +148,7 @@ public async Task PrivateUserIsMemberOfSponsorable() services.GetRequiredService>(), httpFactory, services.GetRequiredService(), - services.GetRequiredService(), + services.GetRequiredService(), oss, Mock.Of>()); var types = await manager.GetSponsorTypeAsync(); @@ -148,7 +166,7 @@ public async Task GetPublicOrgSponsor() services.GetRequiredService>(), httpFactory, services.GetRequiredService(), - services.GetRequiredService(), + services.GetRequiredService(), oss, Mock.Of>()); Assert.Equal(SponsorTypes.Organization, await manager.GetSponsorTypeAsync()); @@ -183,7 +201,7 @@ public async Task GetSponsorshipViaOrganizationMembership() Mock.Of(x => x.CreateClient("sponsorable") == sponsorable && x.CreateClient("sponsor") == graph.Object), - services.GetRequiredService(), + services.GetRequiredService(), oss, Mock.Of>()); Assert.Equal(SponsorTypes.Team, await manager.GetSponsorTypeAsync()); @@ -217,11 +235,17 @@ public async Task GetSponsorshipViaOrganizationEmail() x.CreateClient("sponsorable") == sponsorable && x.CreateClient("sponsor") == graph.Object), services.GetRequiredService(), + oss, Mock.Of>()); var types = await manager.GetSponsorTypeAsync(); Assert.True(types.HasFlag(SponsorTypes.Organization)); + + // If authenticated user is an oss author, we should have the flag too. + var login = await sponsor.QueryAsync(GraphQueries.ViewerAccount); + if (login != null && await oss is { } data && data.Authors.ContainsKey(login.Login)) + Assert.True(types.HasFlag(SponsorTypes.OpenSource)); } [SecretsFact("GitHub:Token", "GitHub:PublicOrg")] @@ -235,7 +259,7 @@ public async Task GetSponsorshipClaims() services.GetRequiredService>(), httpFactory, services.GetRequiredService(), - services.GetRequiredService(), + services.GetRequiredService(), oss, Mock.Of>()); var claims = await manager.GetSponsorClaimsAsync(); @@ -243,7 +267,9 @@ public async Task GetSponsorshipClaims() Assert.NotNull(claims); Assert.Contains(claims, claim => claim.Type == "roles" && claim.Value == "org"); - var manifest = SponsorableManifest.Create(new Uri("https://sponsorlink.devlooped.com"), [new Uri("https://github.com/devlooped")], claims.First(c => c.Type == "client_id").Value); + var sponsorable = await manager.GetManifestAsync(); + + var manifest = SponsorableManifest.Create(new Uri(sponsorable.Issuer), [new Uri("https://github.com/" + sponsorable.Sponsorable)], claims.First(c => c.Type == "client_id").Value); var jwt = manifest.Sign(claims); @@ -268,7 +294,7 @@ public async Task GetTiersWithMetadata() Options.Create(new SponsorLinkOptions { Account = "devlooped" }), httpFactory, services.GetRequiredService(), - services.GetRequiredService(), + services.GetRequiredService(), oss, Mock.Of>()); var tiers = await manager.GetTiersAsync(); @@ -309,14 +335,15 @@ public async Task GetTiersWithMetadata() [InlineData("KirillOsenkov", "silver", SponsorTypes.User)] [InlineData("victorgarciaaprea", "basic", SponsorTypes.Organization)] [InlineData("kzu", "team", SponsorTypes.Team)] - [InlineData("stakx", "contrib", SponsorTypes.None)] // since only *recent* (1yr) contributions count + [InlineData("stakx", "oss", SponsorTypes.OpenSource)] // since he's currently active on castle + [InlineData("test", "none", SponsorTypes.None)] // https://github.com/test not contributing to nugets public async Task GetTierForLogin(string login, string tier, SponsorTypes type) { var manager = new SponsorsManager( services.GetRequiredService>(), httpFactory, services.GetRequiredService(), - services.GetRequiredService(), + services.GetRequiredService(), oss, Mock.Of>()); var sponsor = await manager.FindSponsorAsync(login); diff --git a/src/Tests/SyncCommandTests.cs b/src/Tests/SyncCommandTests.cs index 6b87bc19..15b7c09d 100644 --- a/src/Tests/SyncCommandTests.cs +++ b/src/Tests/SyncCommandTests.cs @@ -1,5 +1,6 @@ using Devlooped.Sponsors; using DotNetConfig; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moq; using Spectre.Console.Cli; @@ -73,14 +74,15 @@ public async Task ExplicitSponsorableSync_NoSponsorableManifest() EnsureAuthenticated(); var graph = new Mock(); - // Return default 'main' branch from GraphQueries.DefaultBranch - graph.Setup(x => x.QueryAsync(GraphQueries.DefaultBranch("kzu", ".github"))).ReturnsAsync("main"); + var auth = new Mock(MockBehavior.Strict); + auth.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny>(), false, "com.devlooped", null)) + .ReturnsAsync(Configuration["GitHub:Token"]); var command = new SyncCommand( Mock.Of(MockBehavior.Strict), config, graph.Object, - Mock.Of(MockBehavior.Strict), + auth.Object, Services.GetRequiredService()); var settings = new SyncCommand.SyncSettings @@ -141,6 +143,10 @@ public async Task ExplicitSponsorableSync_NonSponsoringUser() Unattended = true, }; + var manifestFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sponsorlink", "github", "devlooped.jwt"); + if (File.Exists(manifestFile)) + File.Delete(manifestFile); + var result = await command.ExecuteAsync(new CommandContext(["sync"], Mock.Of(), "sync", null), settings); Assert.Equal(SyncCommand.ErrorCodes.NotSponsoring, result); diff --git a/src/Tests/System/Threading/Tasks/AsyncLazy.cs b/src/Tests/System/Threading/Tasks/AsyncLazy.cs deleted file mode 100644 index c0955cb8..00000000 --- a/src/Tests/System/Threading/Tasks/AsyncLazy.cs +++ /dev/null @@ -1,95 +0,0 @@ -// -#region License -// MIT License -// -// Copyright (c) Daniel Cazzulino -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -#endregion - -using System.Runtime.CompilerServices; - -namespace System.Threading.Tasks -{ - /// - /// Provides factory methods to create so that - /// the T can be inferred from the received value factory return type. - /// - /// - /// Usage: - /// - /// var lazy = AsyncLazy.Create(() => ...); - /// - /// var value = await lazy.Value; - /// - /// - static partial class AsyncLazy - { - /// - /// Creates an using the given . - /// - public static AsyncLazy Create(Func valueFactory) => new AsyncLazy(valueFactory); - - /// - /// Creates an using the given . - /// - public static AsyncLazy Create(Func> asyncValueFactory) => new AsyncLazy(asyncValueFactory); - } - - /// - /// A that initializes asynchronously and whose - /// can be awaited for initialization completion. - /// - /// - /// Basically taken from https://devblogs.microsoft.com/pfxteam/asynclazyt/. - /// Usage: - /// - /// var lazy = new AsyncLazy<T>(() => ...); - /// - /// var value = await lazy.Value; - /// - /// - /// The type of async lazily-initialized value. - partial class AsyncLazy : Lazy> - { - /// - /// Initializes the lazy, using to asynchronously - /// schedule the value factory execution. - /// - public AsyncLazy(Func valueFactory) : base(() => Task.Run(valueFactory)) - { } - - /// - /// Initializes the lazy, using to asynchronously - /// schedule the value factory execution. - /// - public AsyncLazy(Func> asyncValueFactory) : base(() => Task.Run(() => asyncValueFactory())) - { } - - /// - /// Allows awaiting the async lazy directly. - /// - public TaskAwaiter GetAwaiter() => Value.GetAwaiter(); - - /// - /// Gets a value indicating whether the value factory has been invoked and has run to completion. - /// - public bool IsValueFactoryCompleted => base.Value.IsCompleted; - } -} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 0c29569e..b52d72c3 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -1,5 +1,7 @@ using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Security.Cryptography; +using System.Text.Json; using Devlooped; using Devlooped.Sponsors; using Microsoft.ApplicationInsights; @@ -123,6 +125,23 @@ services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + services.AddSingleton(sp => new AsyncLazy(async () => + { + using var http = sp.GetRequiredService().CreateClient(); + var response = await http.GetAsync("https://raw.githubusercontent.com/devlooped/nuget/refs/heads/main/nuget.json"); + + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"Failed to fetch open source data: {response.StatusCode}"); + + var oss = await JsonSerializer.DeserializeAsync(response.Content.ReadAsStream(), JsonOptions.Default); + + if (oss is null) + throw new InvalidOperationException("Failed to deserialize open source data."); + + return oss; + })); + }) .ConfigureGitHubWebhooks(new ConfigurationBuilder().Configure().Build()["GitHub:Secret"]) .Build();