diff --git a/locales/en-US/browser/browser/zen-live-folders.ftl b/locales/en-US/browser/browser/zen-live-folders.ftl index 79988c1dce..477721015a 100644 --- a/locales/en-US/browser/browser/zen-live-folders.ftl +++ b/locales/en-US/browser/browser/zen-live-folders.ftl @@ -99,3 +99,23 @@ zen-live-folder-github-option-repo-list-note = zen-live-folders-promotion-title = Live Folder Created! zen-live-folders-promotion-description = Latest content from your RSS feeds or GitHub pull requests will appear here automatically. + +zen-live-folder-github-prompt-instance = Enter the GitHub instance URL + +zen-live-folder-github-option-instance = + .label = Instance: { $host } + +zen-live-folder-github-invalid-url-title = Invalid GitHub URL +zen-live-folder-github-invalid-url-description = The URL must be a valid HTTPS address for a GitHub instance. + +zen-live-folder-github-prompt-token = Enter your GitHub Personal Access Token + +zen-live-folder-github-option-set-token = + .label = Set Access Token… + +zen-live-folder-github-option-remove-token = + .label = Remove Access Token + +zen-live-folder-github-token-expired = + .label = Access token expired + .tooltiptext = Your access token has expired or been revoked. Click to set a new one. diff --git a/src/zen/live-folders/ZenLiveFolder.sys.mjs b/src/zen/live-folders/ZenLiveFolder.sys.mjs index 821a1fb290..54ed5d2c1e 100644 --- a/src/zen/live-folders/ZenLiveFolder.sys.mjs +++ b/src/zen/live-folders/ZenLiveFolder.sys.mjs @@ -122,7 +122,7 @@ export class nsZenLiveFolderProvider { this.manager.saveState(); } - fetch(url, { maxContentLength = 5 * 1024 * 1024 } = {}) { + fetch(url, { maxContentLength = 5 * 1024 * 1024, headers = {} } = {}) { const uri = lazy.NetUtil.newURI(url); // TODO: Support userContextId when fetching, it should be inherited from the folder's // current space context ID. @@ -155,6 +155,10 @@ export class nsZenLiveFolderProvider { triggeringPrincipal: principal, }).QueryInterface(Ci.nsIHttpChannel); + for (const [name, value] of Object.entries(headers)) { + channel.setRequestHeader(name, value, false); + } + let httpStatus = null; let contentType = ""; let headerCharset = null; diff --git a/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs b/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs index 786fc2452b..39b08c029f 100644 --- a/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs +++ b/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs @@ -9,6 +9,7 @@ ChromeUtils.defineESModuleGetters(lazy, { TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", ZenWindowSync: "resource:///modules/zen/ZenWindowSync.sys.mjs", FeatureCallout: "resource:///modules/asrouter/FeatureCallout.sys.mjs", + GithubTokenManager: "resource:///modules/zen/GithubAuth.sys.mjs", }); ChromeUtils.defineLazyGetter( @@ -208,6 +209,7 @@ class nsZenLiveFoldersManager { } let url; + let host; let label; let icon; @@ -225,11 +227,25 @@ class nsZenLiveFoldersManager { break; } case "github": { + // First GitHub folder defaults to github.com, subsequent ones show prompt + if (this.hasGitHubLiveFolder()) { + host = await ProviderClass.promptForHost(this.window); + if (!host) { + return -1; + } + } else { + host = "https://github.com"; + } + const [message] = await lazy.l10n.formatMessages([ { id: `zen-live-folder-github-${providerType}` }, ]); - label = message.attributes[0].value; + const hostname = new URL(host).hostname; + label = + hostname === "github.com" + ? message.attributes[0].value + : `${message.attributes[0].value} (${hostname})`; icon = "chrome://browser/skin/zen-icons/selectable/logo-github.svg"; break; } @@ -250,6 +266,7 @@ class nsZenLiveFoldersManager { const config = { state: this.#applyDefaultStateValues({ url, + host, type: providerType, }), }; @@ -356,6 +373,21 @@ class nsZenLiveFoldersManager { } liveFolder.stop(); + + // Clean up stored PAT if this is a GitHub folder and no other folder shares the host + if (liveFolder.constructor.type === "github" && liveFolder.state.host) { + const host = liveFolder.state.host; + const otherFolderUsesHost = Array.from(this.liveFolders.values()).some( + f => + f !== liveFolder && + f.constructor.type === "github" && + f.state.host === host + ); + if (!otherFolderUsesHost) { + lazy.GithubTokenManager.removeToken(host).catch(() => {}); + } + } + this.liveFolders.delete(id); const prefix = `${id}:`; @@ -502,6 +534,15 @@ class nsZenLiveFoldersManager { // Helpers // ------- + hasGitHubLiveFolder() { + for (const liveFolder of this.liveFolders.values()) { + if (liveFolder.constructor.type === "github") { + return true; + } + } + return false; + } + #applyDefaultStateValues(state) { state.interval ||= DEFAULT_FETCH_INTERVAL; state.lastFetched ||= 0; diff --git a/src/zen/live-folders/moz.build b/src/zen/live-folders/moz.build index 37c474035f..a84b8c31a2 100644 --- a/src/zen/live-folders/moz.build +++ b/src/zen/live-folders/moz.build @@ -3,6 +3,7 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. EXTRA_JS_MODULES.zen += [ + "providers/GithubAuth.sys.mjs", "providers/GithubLiveFolder.sys.mjs", "providers/RssLiveFolder.sys.mjs", "ZenLiveFolder.sys.mjs", diff --git a/src/zen/live-folders/providers/GithubAuth.sys.mjs b/src/zen/live-folders/providers/GithubAuth.sys.mjs new file mode 100644 index 0000000000..ae16900af5 --- /dev/null +++ b/src/zen/live-folders/providers/GithubAuth.sys.mjs @@ -0,0 +1,115 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "l10n", () => new Localization(["browser/zen-live-folders.ftl"])); + +const LoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +export class GithubTokenManager { + static REALM = "zen-live-folder-github-pat"; + + /** + * Returns the stored PAT for the given origin, or null if none exists. + * + * @param {string} origin - The GitHub host origin (e.g. "https://github.com") + * @returns {Promise} + */ + static async getToken(origin) { + const logins = await Services.logins.searchLoginsAsync({ + origin, + httpRealm: GithubTokenManager.REALM, + }); + if (logins.length > 0) { + return logins[0].password; + } + return null; + } + + /** + * Stores or updates a PAT for the given origin. + * + * @param {string} origin - The GitHub host origin + * @param {string} token - The personal access token + */ + static async setToken(origin, token) { + const logins = await Services.logins.searchLoginsAsync({ + origin, + httpRealm: GithubTokenManager.REALM, + }); + + if (logins.length > 0) { + const oldLogin = logins[0]; + const newLoginData = oldLogin.clone(); + newLoginData.password = token; + await Services.logins.modifyLoginAsync(oldLogin, newLoginData); + } else { + const loginInfo = new LoginInfo( + origin, + null, // formActionOrigin + GithubTokenManager.REALM, + "", // username + token, + "", // usernameField + "" // passwordField + ); + await Services.logins.addLoginAsync(loginInfo); + } + } + + /** + * Removes the stored PAT for the given origin. + * + * @param {string} origin - The GitHub host origin + */ + static async removeToken(origin) { + const logins = await Services.logins.searchLoginsAsync({ + origin, + httpRealm: GithubTokenManager.REALM, + }); + if (logins.length > 0) { + await Services.logins.removeLoginAsync(logins[0]); + } + } + + /** + * Shows a password prompt for the user to enter a PAT, validates it, + * stores it if valid, and returns whether a token was successfully stored. + * + * @param {Window} window - The browser window for the prompt + * @param {string} origin - The GitHub host origin + * @returns {Promise} + */ + static async promptForToken(window, origin) { + const title = await lazy.l10n.formatValue("zen-live-folder-github-prompt-token"); + const passwordObj = { value: "" }; + const checkObj = { value: false }; + + const ok = Services.prompt.promptPassword( + window, + title, + title, + passwordObj, + null, + checkObj + ); + + if (!ok) { + return false; + } + + const token = passwordObj.value.trim(); + if (!token) { + return false; + } + + await GithubTokenManager.setToken(origin, token); + return true; + } +} diff --git a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs index 1deeb6f439..e9b89e5a0e 100644 --- a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs +++ b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs @@ -3,6 +3,14 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/. import { nsZenLiveFolderProvider } from "resource:///modules/zen/ZenLiveFolder.sys.mjs"; +import { GithubTokenManager } from "resource:///modules/zen/GithubAuth.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization(["browser/zen-live-folders.ftl"]) +); export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { static type = "github"; @@ -11,24 +19,44 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { super({ id, state, manager }); this.state.type = state.type; + this.state.host = state.host || "https://github.com"; this.state.url = this.state.type === "pull-requests" - ? "https://github.com/pulls" - : "https://github.com/issues/assigned"; + ? new URL("/pulls", this.state.host).href + : new URL("/issues/assigned", this.state.host).href; this.state.options = state.options ?? {}; this.state.repos = new Set(state.repos ?? []); this.state.options.repoExcludes = new Set(state.options.repoExcludes ?? []); + this.state._hasToken = false; + } + + get #isGitHubEnterprise() { + return new URL(this.state.host).hostname !== "github.com"; + } + + get #hasAnyFilterEnabled() { + return ( + (this.state.options.authorMe ?? false) || + (this.state.options.assignedMe ?? true) || + (this.state.options.reviewRequested ?? false) + ); } async fetchItems() { try { - const hasAnyFilterEnabled = - (this.state.options.authorMe ?? false) || - (this.state.options.assignedMe ?? true) || - (this.state.options.reviewRequested ?? false); + const token = await GithubTokenManager.getToken(this.state.host); + this.state._hasToken = !!token; + if (token) { + return this.#fetchItemsViaApi(token); + } - if (!hasAnyFilterEnabled) { + // GHE instances require a PAT — HTML scraping won't work without cookies + if (this.#isGitHubEnterprise) { + return "zen-live-folder-github-no-auth"; + } + + if (!this.#hasAnyFilterEnabled) { return "zen-live-folder-github-no-filter"; } @@ -50,8 +78,8 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { const combinedActiveRepos = new Set(); for (const { status, items, activeRepos } of requests) { - // Assume no auth - if (status === 404) { + // Any non-2xx status likely means not authenticated + if (status && (status < 200 || status >= 300)) { return "zen-live-folder-github-no-auth"; } @@ -65,6 +93,12 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { } this.state.repos = combinedActiveRepos; + + // A 200 with no items likely means we got a login page instead of real content + if (combinedItems.size === 0) { + return "zen-live-folder-github-no-auth"; + } + return Array.from(combinedItems.values()); } catch (error) { console.error("Error fetching or parsing GitHub issues:", error); @@ -72,6 +106,94 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { } } + async #fetchItemsViaApi(token) { + try { + if (!this.#hasAnyFilterEnabled) { + return "zen-live-folder-github-no-filter"; + } + + const queries = this.#buildSearchOptions(); + const apiBase = this.#getApiBaseUrl(); + + const combinedItems = new Map(); + const combinedActiveRepos = new Set(); + + const results = await Promise.allSettled( + queries.map(async query => { + const url = new URL(`${apiBase}/search/issues`); + url.searchParams.set("q", query); + url.searchParams.set("per_page", "50"); + + return this.fetch(url.href, { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }); + }) + ); + + for (const result of results) { + if (result.status !== "fulfilled") { + continue; + } + + const { text, status } = result.value; + + if (status === 401 || status === 403) { + await GithubTokenManager.removeToken(this.state.host); + this.state._hasToken = false; + return "zen-live-folder-github-token-expired"; + } + + if (status && (status < 200 || status >= 300)) { + continue; + } + + try { + const data = JSON.parse(text); + if (data.items) { + for (const item of data.items) { + const repoFullName = item.repository_url + ? item.repository_url.replace(/.*\/repos\//, "") + : ""; + const id = `${repoFullName}#${item.number}`; + + if (repoFullName) { + combinedActiveRepos.add(repoFullName); + } + + combinedItems.set(id, { + title: item.title, + subtitle: item.user?.login || "", + icon: "chrome://browser/content/zen-images/favicons/github.svg", + url: item.html_url, + id, + }); + } + } + } catch { + // JSON parse failure + } + } + + this.state.repos = combinedActiveRepos; + return Array.from(combinedItems.values()); + } catch (error) { + console.error("Error fetching GitHub API:", error); + return "zen-live-folder-failed-fetch"; + } + } + + #getApiBaseUrl() { + const hostUrl = new URL(this.state.host); + if (hostUrl.hostname === "github.com") { + return "https://api.github.com"; + } + // GitHub Enterprise Server uses /api/v3 prefix + return `${this.state.host}/api/v3`; + } + async parsePullRequests(url) { const { text, status } = await this.fetch(url); @@ -162,7 +284,7 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { title, subtitle: author, icon: "chrome://browser/content/zen-images/favicons/github.svg", - url: "https://github.com" + issueUrl, + url: new URL(issueUrl, this.state.host).href, id: `${repo}#${number}`, }); } @@ -280,10 +402,26 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { // 1 repo + separator + note = 3 options, so if we have less than 4 options it means we don't have any repo to exclude disabled: repoOptions.length < 4, }, + { type: "separator" }, + { + l10nId: "zen-live-folder-github-option-instance", + l10nArgs: { host: new URL(this.state.host).hostname }, + key: "githubInstance", + }, + { + l10nId: "zen-live-folder-github-option-set-token", + key: "setToken", + hidden: this.state._hasToken === true, + }, + { + l10nId: "zen-live-folder-github-option-remove-token", + key: "removeToken", + hidden: this.state._hasToken !== true, + }, ]; } - onOptionTrigger(option) { + async onOptionTrigger(option) { super.onOptionTrigger(option); const key = option.getAttribute("option-key"); @@ -292,6 +430,47 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { return; } + if (key === "setToken") { + const success = await GithubTokenManager.promptForToken( + this.manager.window, + this.state.host + ); + if (success) { + this.state._hasToken = true; + this.refresh(); + } + return; + } + + if (key === "removeToken") { + await GithubTokenManager.removeToken(this.state.host); + this.state._hasToken = false; + this.refresh(); + return; + } + + if (key === "githubInstance") { + const host = await nsGithubLiveFolderProvider.promptForHost( + this.manager.window, + this.state.host + ); + if (host && host !== this.state.host) { + this.state.host = host; + const path = + this.state.type === "pull-requests" ? "/pulls" : "/issues/assigned"; + this.state.url = new URL(path, host).href; + + // For GHE hosts, open the PAT creation page + if (this.#isGitHubEnterprise) { + this.#openPatCreationPage(host); + } + + this.refresh(); + this.requestSave(); + } + return; + } + if (key === "repoExclude") { const repo = option.getAttribute("option-value"); if (!repo) { @@ -319,23 +498,81 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { switch (errorId) { case "zen-live-folder-github-no-auth": { - const tab = this.manager.window.gBrowser.addTrustedTab( - "https://github.com/login" - ); - this.manager.window.gBrowser.selectedTab = tab; + if (this.#isGitHubEnterprise) { + // For GHE instances, open the PAT creation page + this.#openPatCreationPage(this.state.host); + } else { + // For github.com, open the login page + const tab = this.manager.window.gBrowser.addTrustedTab( + new URL("/login", this.state.host).href + ); + this.manager.window.gBrowser.selectedTab = tab; + } break; } case "zen-live-folder-github-no-filter": { this.refresh(); break; } + case "zen-live-folder-github-token-expired": { + this.#openPatCreationPage(this.state.host); + break; + } + } + } + + #openPatCreationPage(host) { + const tokenUrl = new URL("/settings/tokens/new", host); + tokenUrl.searchParams.set("scopes", "repo"); + tokenUrl.searchParams.set("description", "Zen Browser Live Folders"); + + const tab = this.manager.window.gBrowser.addTrustedTab(tokenUrl.href); + this.manager.window.gBrowser.selectedTab = tab; + } + + static async promptForHost(window, initialUrl = "https://github.com") { + const input = { value: initialUrl }; + const [prompt] = await lazy.l10n.formatValues([ + "zen-live-folder-github-prompt-instance", + ]); + const promptOk = Services.prompt.prompt( + window, + prompt, + null, + input, + null, + { value: null } + ); + + if (!promptOk) { + return null; + } + + try { + const raw = (input.value ?? "").trim(); + const parsed = new URL(raw); + if (parsed.protocol !== "https:") { + throw new Error(); + } + return parsed.origin; + } catch { + window.gZenUIManager.showToast( + "zen-live-folder-github-invalid-url-title", + { + descriptionId: "zen-live-folder-github-invalid-url-description", + timeout: 6000, + } + ); } + + return null; } serialize() { + const { _hasToken, ...serializableState } = this.state; return { state: { - ...this.state, + ...serializableState, repos: Array.from(this.state.repos), options: { ...this.state.options, diff --git a/src/zen/tests/live-folders/browser_github_live_folder.js b/src/zen/tests/live-folders/browser_github_live_folder.js index ded5e51166..02c390e99e 100644 --- a/src/zen/tests/live-folders/browser_github_live_folder.js +++ b/src/zen/tests/live-folders/browser_github_live_folder.js @@ -26,6 +26,7 @@ function getGithubProviderForTest(sandbox, customOptions = {}) { maxItems: 10, lastFetched: 0, type: customOptions.type, + host: customOptions.host, options: defaultOptions, }; @@ -65,7 +66,10 @@ add_task(async function test_fetch_items_url_construction() { const fetchedUrl = new URL(instance.fetch.firstCall.args[0]); const searchParams = fetchedUrl.searchParams; - Assert.ok(fetchedUrl.href.startsWith("https://github.com/issues/assigned")); + Assert.ok( + fetchedUrl.href.startsWith("https://github.com/pulls"), + "PR type should use /pulls endpoint" + ); const query = searchParams.get("q"); Assert.ok(query.includes("state:open"), "Should include state:open"); @@ -176,3 +180,150 @@ add_task(async function test_fetch_network_error() { sandbox.restore(); }); + +add_task(async function test_ghe_without_token_returns_auth_error() { + info("GHE instance without token should return auth error immediately"); + + let sandbox = sinon.createSandbox(); + + let instance = getGithubProviderForTest(sandbox, { + authorMe: true, + assignedMe: false, + reviewRequested: false, + type: "pull-requests", + host: "https://github.corp.com", + }); + + instance.fetch.resolves({ + status: 200, + text: "", + }); + + const result = await instance.fetchItems(); + + Assert.equal( + result, + "zen-live-folder-github-no-auth", + "GHE without token should return auth error" + ); + Assert.ok( + !instance.fetch.called, + "Should not attempt to fetch without a token for GHE" + ); + + sandbox.restore(); +}); + +add_task(async function test_custom_host_state_construction() { + info("should construct state correctly with custom host"); + + let sandbox = sinon.createSandbox(); + + // PR type + let prInstance = getGithubProviderForTest(sandbox, { + type: "pull-requests", + host: "https://github.corp.com", + }); + Assert.equal( + prInstance.state.host, + "https://github.corp.com", + "Custom host should be preserved" + ); + Assert.ok( + prInstance.state.url.startsWith("https://github.corp.com/pulls"), + "URL should use custom host for PRs" + ); + + // Issues type + let issueInstance = getGithubProviderForTest(sandbox, { + type: "issues", + host: "https://github.corp.com", + }); + Assert.ok( + issueInstance.state.url.startsWith( + "https://github.corp.com/issues/assigned" + ), + "URL should use custom host for issues" + ); + + sandbox.restore(); +}); + +add_task(async function test_non_2xx_triggers_auth_error() { + info("should treat non-2xx responses as auth errors for github.com"); + + let sandbox = sinon.createSandbox(); + let instance = getGithubProviderForTest(sandbox, { + type: "pull-requests", + }); + + instance.fetch.resolves({ + status: 403, + text: "Forbidden", + }); + + const errorId = await instance.fetchItems(); + Assert.equal( + errorId, + "zen-live-folder-github-no-auth", + "Should return auth error for 403 status" + ); + + sandbox.restore(); +}); + +add_task(async function test_empty_results_triggers_auth_error() { + info("should treat empty results as auth error (login page returned)"); + + let sandbox = sinon.createSandbox(); + let instance = getGithubProviderForTest(sandbox); + + instance.fetch.resolves({ + status: 200, + text: "Please log in", + }); + + const errorId = await instance.fetchItems(); + Assert.equal( + errorId, + "zen-live-folder-github-no-auth", + "Should return auth error when 200 but no items parsed" + ); + + sandbox.restore(); +}); + +add_task(async function test_state_host_defaults() { + info("should default host to github.com when not specified"); + + let sandbox = sinon.createSandbox(); + + let instance = getGithubProviderForTest(sandbox, { + type: "pull-requests", + }); + Assert.equal( + instance.state.host, + "https://github.com", + "Default host should be github.com" + ); + Assert.ok( + instance.state.url.startsWith("https://github.com/pulls"), + "URL should use github.com for PRs" + ); + + let gheInstance = getGithubProviderForTest(sandbox, { + type: "issues", + host: "https://github.corp.com", + }); + Assert.equal( + gheInstance.state.host, + "https://github.corp.com", + "Custom host should be preserved" + ); + Assert.ok( + gheInstance.state.url.startsWith("https://github.corp.com/issues/assigned"), + "URL should use custom host for issues" + ); + + sandbox.restore(); +});