From 02efd5f544b808cf97b4fe8657b6d46c90b06507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 1 Apr 2026 15:22:54 -0400 Subject: [PATCH 1/6] feat: Add GitHub Enterprise instance support to Live Folders Allow each GitHub live folder to target a configurable GitHub instance instead of hardcoding github.com. Users are prompted for the instance URL when creating a folder, and can change it later via the context menu "Instance" option. - Add `host` to GitHub provider state (defaults to github.com) - Derive fetch URL and login redirect from the configured host - Add `promptForHost()` static method (follows RSS pattern) - Add "Instance: hostname" option in Live Folder context menu - Broaden auth error detection (any non-2xx + empty results) - Add localization strings for instance prompt and validation - Update tests for custom host support Related to zen-browser/desktop#12768 --- .../browser/browser/zen-live-folders.ftl | 8 ++ .../ZenLiveFoldersManager.sys.mjs | 13 ++- .../providers/GithubLiveFolder.sys.mjs | 91 +++++++++++++++++-- .../browser_github_live_folder.js | 75 ++++++++++++++- 4 files changed, 176 insertions(+), 11 deletions(-) diff --git a/locales/en-US/browser/browser/zen-live-folders.ftl b/locales/en-US/browser/browser/zen-live-folders.ftl index 79988c1dce5..dd2edabfe79 100644 --- a/locales/en-US/browser/browser/zen-live-folders.ftl +++ b/locales/en-US/browser/browser/zen-live-folders.ftl @@ -97,5 +97,13 @@ zen-live-folder-github-issues = zen-live-folder-github-option-repo-list-note = .label = This list is generated based on your currently active pull requests. +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-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. diff --git a/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs b/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs index 786fc2452b3..f84d499ce97 100644 --- a/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs +++ b/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs @@ -208,6 +208,7 @@ class nsZenLiveFoldersManager { } let url; + let host; let label; let icon; @@ -225,11 +226,20 @@ class nsZenLiveFoldersManager { break; } case "github": { + host = await ProviderClass.promptForHost(this.window); + if (!host) { + return -1; + } + 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 +260,7 @@ class nsZenLiveFoldersManager { const config = { state: this.#applyDefaultStateValues({ url, + host, type: providerType, }), }; diff --git a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs index 1deeb6f4390..1a16c26098a 100644 --- a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs +++ b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs @@ -4,6 +4,13 @@ import { nsZenLiveFolderProvider } from "resource:///modules/zen/ZenLiveFolder.sys.mjs"; +const lazy = {}; +ChromeUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization(["browser/zen-live-folders.ftl"]) +); + export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { static type = "github"; @@ -11,10 +18,10 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { super({ id, state, manager }); this.state.type = state.type; - this.state.url = - this.state.type === "pull-requests" - ? "https://github.com/pulls" - : "https://github.com/issues/assigned"; + this.state.host = state.host || "https://github.com"; + const path = + this.state.type === "pull-requests" ? "/pulls" : "/issues/assigned"; + this.state.url = new URL(path, this.state.host).href; this.state.options = state.options ?? {}; this.state.repos = new Set(state.repos ?? []); @@ -50,8 +57,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 +72,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); @@ -162,7 +175,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 +293,16 @@ 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", + }, ]; } - onOptionTrigger(option) { + async onOptionTrigger(option) { super.onOptionTrigger(option); const key = option.getAttribute("option-key"); @@ -292,6 +311,22 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { 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; + this.refresh(); + this.requestSave(); + } + return; + } + if (key === "repoExclude") { const repo = option.getAttribute("option-value"); if (!repo) { @@ -320,7 +355,7 @@ 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" + new URL("/login", this.state.host).href ); this.manager.window.gBrowser.selectedTab = tab; break; @@ -332,6 +367,44 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { } } + 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 !== "http:" && 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() { return { state: { 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 ded5e511669..3ae14d2f0bd 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,72 @@ add_task(async function test_fetch_network_error() { sandbox.restore(); }); + +add_task(async function test_custom_host_url_construction() { + info("should use custom GitHub Enterprise host for fetch URLs"); + + 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: "", + }); + + await instance.fetchItems(); + + Assert.ok(instance.fetch.calledOnce, "Fetch should be called once"); + + const fetchedUrl = new URL(instance.fetch.firstCall.args[0]); + Assert.ok( + fetchedUrl.href.startsWith("https://github.corp.com/pulls"), + "Should use custom host for PR endpoint" + ); + + sandbox.restore(); +}); + +add_task(async function test_custom_host_issue_parsing() { + info("should use custom host when parsing issue URLs"); + + let sandbox = sinon.createSandbox(); + let instance = getGithubProviderForTest(sandbox, { + host: "https://github.corp.com", + }); + + const mockHtml = ` + + +
+
org/repo#42
+ TestUser +
Test issue
+ +
+ + + `; + + instance.fetch.resolves({ + text: mockHtml, + status: 200, + }); + + const items = await instance.fetchItems(); + + Assert.equal(items.length, 1, "Should find 1 item"); + Assert.equal( + items[0].url, + "https://github.corp.com/issues/42", + "Should use custom host in parsed issue URL" + ); + + sandbox.restore(); +}); From 20ffae3665ca37b8602c9e87728f06fdeb62e90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 1 Apr 2026 15:43:14 -0400 Subject: [PATCH 2/6] feat: Add PAT authentication and REST API support for GitHub Enterprise - Add GithubAuth.sys.mjs: secure PAT storage via Firefox Login Manager (tokens stored with unique httpRealm, never in JSON state file) - Add REST API fetch path: when a PAT is available, use GitHub's /search/issues API instead of HTML scraping (more reliable for GHE) - Add headers support to base fetch() method via channel.setRequestHeader - Add "Set/Remove Access Token" context menu options - Open PAT creation page with pre-selected scopes when configuring GHE - Skip host prompt for first GitHub folder (defaults to github.com) - Handle token expiry: clear stored token on 401/403, prompt for new one - Add comprehensive tests for custom host, auth detection, and state defaults --- .../browser/browser/zen-live-folders.ftl | 16 +- src/zen/live-folders/ZenLiveFolder.sys.mjs | 6 +- .../ZenLiveFoldersManager.sys.mjs | 20 ++- .../live-folders/providers/GithubAuth.sys.mjs | 126 +++++++++++++ .../providers/GithubLiveFolder.sys.mjs | 169 +++++++++++++++++- .../browser_github_live_folder.js | 80 +++++++++ 6 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 src/zen/live-folders/providers/GithubAuth.sys.mjs diff --git a/locales/en-US/browser/browser/zen-live-folders.ftl b/locales/en-US/browser/browser/zen-live-folders.ftl index dd2edabfe79..477721015a0 100644 --- a/locales/en-US/browser/browser/zen-live-folders.ftl +++ b/locales/en-US/browser/browser/zen-live-folders.ftl @@ -97,6 +97,9 @@ zen-live-folder-github-issues = zen-live-folder-github-option-repo-list-note = .label = This list is generated based on your currently active pull requests. +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 = @@ -105,5 +108,14 @@ zen-live-folder-github-option-instance = 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-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-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 821a1fb2905..54ed5d2c1ed 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 f84d499ce97..d1cfad02342 100644 --- a/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs +++ b/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs @@ -226,9 +226,14 @@ class nsZenLiveFoldersManager { break; } case "github": { - host = await ProviderClass.promptForHost(this.window); - if (!host) { - return -1; + // 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([ @@ -513,6 +518,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/providers/GithubAuth.sys.mjs b/src/zen/live-folders/providers/GithubAuth.sys.mjs new file mode 100644 index 00000000000..3e26d7a7cb6 --- /dev/null +++ b/src/zen/live-folders/providers/GithubAuth.sys.mjs @@ -0,0 +1,126 @@ +// 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]); + } + } + + /** + * Returns whether a PAT is stored for the given origin. + * + * @param {string} origin - The GitHub host origin + * @returns {Promise} + */ + static async hasToken(origin) { + const token = await GithubTokenManager.getToken(origin); + return token !== null; + } + + /** + * 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 1a16c26098a..c0b9573624e 100644 --- a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs +++ b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs @@ -3,6 +3,7 @@ // 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( @@ -18,18 +19,26 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { super({ id, state, manager }); this.state.type = state.type; - this.state.host = state.host || "https://github.com"; - const path = - this.state.type === "pull-requests" ? "/pulls" : "/issues/assigned"; - this.state.url = new URL(path, this.state.host).href; + this.state.host = state.host ?? "https://github.com"; + this.state.url = + this.state.type === "pull-requests" + ? 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; } async fetchItems() { try { + const token = await GithubTokenManager.getToken(this.state.host); + this.state._hasToken = !!token; + if (token) { + return this.#fetchItemsViaApi(token); + } + const hasAnyFilterEnabled = (this.state.options.authorMe ?? false) || (this.state.options.assignedMe ?? true) || @@ -85,6 +94,94 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { } } + async #fetchItemsViaApi(token) { + try { + const hasAnyFilterEnabled = + (this.state.options.authorMe ?? false) || + (this.state.options.assignedMe ?? true) || + (this.state.options.reviewRequested ?? false); + + if (!hasAnyFilterEnabled) { + return "zen-live-folder-github-no-filter"; + } + + const queries = this.#buildSearchOptions(); + const apiBase = this.#getApiBaseUrl(); + + const combinedItems = new Map(); + const combinedActiveRepos = new Set(); + + for (const query of queries) { + const url = new URL(`${apiBase}/search/issues`); + url.searchParams.set("q", query); + url.searchParams.set("per_page", "50"); + + const { text, status } = await this.fetch(url.href, { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }); + + if (status === 401 || status === 403) { + // Token expired or revoked — clear it and fall back + await GithubTokenManager.removeToken(this.state.host); + return "zen-live-folder-github-token-expired"; + } + + if (status && (status < 200 || status >= 300)) { + return "zen-live-folder-github-no-auth"; + } + + 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; + + if (combinedItems.size === 0) { + return "zen-live-folder-github-no-auth"; + } + + 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); @@ -299,6 +396,16 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { 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, + }, ]; } @@ -311,6 +418,25 @@ 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, @@ -321,6 +447,12 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { const path = this.state.type === "pull-requests" ? "/pulls" : "/issues/assigned"; this.state.url = new URL(path, host).href; + + // For non-github.com hosts, guide user to create a PAT + if (new URL(host).hostname !== "github.com") { + await this.#promptForPat(host); + } + this.refresh(); this.requestSave(); } @@ -364,6 +496,35 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { this.refresh(); break; } + case "zen-live-folder-github-token-expired": { + const success = await GithubTokenManager.promptForToken( + this.manager.window, + this.state.host + ); + if (success) { + this.refresh(); + } + break; + } + } + } + + async #promptForPat(host) { + // Open the PAT creation page with pre-selected scopes + 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; + + // Prompt for the token + const success = await GithubTokenManager.promptForToken( + this.manager.window, + host + ); + if (success) { + this.state._hasToken = true; } } 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 3ae14d2f0bd..167998ed5f7 100644 --- a/src/zen/tests/live-folders/browser_github_live_folder.js +++ b/src/zen/tests/live-folders/browser_github_live_folder.js @@ -249,3 +249,83 @@ add_task(async function test_custom_host_issue_parsing() { sandbox.restore(); }); + +add_task(async function test_non_2xx_triggers_auth_error() { + info("should treat non-2xx responses as auth errors"); + + let sandbox = sinon.createSandbox(); + let instance = getGithubProviderForTest(sandbox, { + type: "pull-requests", + host: "https://github.corp.com", + }); + + 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(); +}); From d93f63e1fba859756fd85000ac22d60f900b7450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 1 Apr 2026 16:29:41 -0400 Subject: [PATCH 3/6] fix: Improve GHE auth UX and error handling - Triangle click always opens PAT creation page (no prompt window) - GHE instances without PAT skip HTML scraping, show auth error directly - Empty API results no longer treated as auth failure (valid empty search) - Register GithubAuth.sys.mjs in moz.build - Remove debug logging --- src/zen/live-folders/moz.build | 1 + .../providers/GithubLiveFolder.sys.mjs | 50 ++++++++----------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/zen/live-folders/moz.build b/src/zen/live-folders/moz.build index 37c474035fe..a84b8c31a2a 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/GithubLiveFolder.sys.mjs b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs index c0b9573624e..8aba13a47d9 100644 --- a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs +++ b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs @@ -39,6 +39,12 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { return this.#fetchItemsViaApi(token); } + // GHE instances require a PAT — HTML scraping won't work without cookies + const isGHE = new URL(this.state.host).hostname !== "github.com"; + if (isGHE) { + return "zen-live-folder-github-no-auth"; + } + const hasAnyFilterEnabled = (this.state.options.authorMe ?? false) || (this.state.options.assignedMe ?? true) || @@ -162,10 +168,7 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { this.state.repos = combinedActiveRepos; - if (combinedItems.size === 0) { - return "zen-live-folder-github-no-auth"; - } - + // API authenticated successfully — empty results just means no matching PRs/issues return Array.from(combinedItems.values()); } catch (error) { console.error("Error fetching GitHub API:", error); @@ -448,9 +451,9 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { this.state.type === "pull-requests" ? "/pulls" : "/issues/assigned"; this.state.url = new URL(path, host).href; - // For non-github.com hosts, guide user to create a PAT + // For non-github.com hosts, open the PAT creation page if (new URL(host).hostname !== "github.com") { - await this.#promptForPat(host); + this.#openPatCreationPage(host); } this.refresh(); @@ -486,10 +489,17 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { switch (errorId) { case "zen-live-folder-github-no-auth": { - const tab = this.manager.window.gBrowser.addTrustedTab( - new URL("/login", this.state.host).href - ); - this.manager.window.gBrowser.selectedTab = tab; + const isGHE = new URL(this.state.host).hostname !== "github.com"; + if (isGHE) { + // 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": { @@ -497,35 +507,19 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { break; } case "zen-live-folder-github-token-expired": { - const success = await GithubTokenManager.promptForToken( - this.manager.window, - this.state.host - ); - if (success) { - this.refresh(); - } + this.#openPatCreationPage(this.state.host); break; } } } - async #promptForPat(host) { - // Open the PAT creation page with pre-selected scopes + #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; - - // Prompt for the token - const success = await GithubTokenManager.promptForToken( - this.manager.window, - host - ); - if (success) { - this.state._hasToken = true; - } } static async promptForHost(window, initialUrl = "https://github.com") { From 86551852e032324658994fd0169eb770a39d3d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 1 Apr 2026 16:36:45 -0400 Subject: [PATCH 4/6] fix: Address security, performance, and correctness audit findings Security: - Exclude _hasToken from serialize() to prevent state file leakage - Only allow HTTPS for GitHub host URLs (reject HTTP) - Clean up stored PAT when folder is deleted (if no other folder shares host) Performance: - Parallelize API queries with Promise.all (saves 400-1000ms per cycle) Correctness: - Use || instead of ?? for host default (prevents empty string breaking URL) - Fix broken GHE tests (GHE early-returns before fetch, so test fetch assertions) - Remove dead hasToken() method from GithubTokenManager --- .../ZenLiveFoldersManager.sys.mjs | 16 +++++ .../live-folders/providers/GithubAuth.sys.mjs | 11 --- .../providers/GithubLiveFolder.sys.mjs | 32 +++++---- .../browser_github_live_folder.js | 72 +++++++++---------- 4 files changed, 69 insertions(+), 62 deletions(-) diff --git a/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs b/src/zen/live-folders/ZenLiveFoldersManager.sys.mjs index d1cfad02342..39b08c029ff 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( @@ -372,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}:`; diff --git a/src/zen/live-folders/providers/GithubAuth.sys.mjs b/src/zen/live-folders/providers/GithubAuth.sys.mjs index 3e26d7a7cb6..ae16900af5c 100644 --- a/src/zen/live-folders/providers/GithubAuth.sys.mjs +++ b/src/zen/live-folders/providers/GithubAuth.sys.mjs @@ -78,17 +78,6 @@ export class GithubTokenManager { } } - /** - * Returns whether a PAT is stored for the given origin. - * - * @param {string} origin - The GitHub host origin - * @returns {Promise} - */ - static async hasToken(origin) { - const token = await GithubTokenManager.getToken(origin); - return token !== null; - } - /** * 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. diff --git a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs index 8aba13a47d9..e90c9da595d 100644 --- a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs +++ b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs @@ -19,7 +19,7 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { super({ id, state, manager }); this.state.type = state.type; - this.state.host = state.host ?? "https://github.com"; + this.state.host = state.host || "https://github.com"; this.state.url = this.state.type === "pull-requests" ? new URL("/pulls", this.state.host).href @@ -117,20 +117,23 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { const combinedItems = new Map(); const combinedActiveRepos = new Set(); - for (const query of queries) { - const url = new URL(`${apiBase}/search/issues`); - url.searchParams.set("q", query); - url.searchParams.set("per_page", "50"); + const requests = await Promise.all( + queries.map(async query => { + const url = new URL(`${apiBase}/search/issues`); + url.searchParams.set("q", query); + url.searchParams.set("per_page", "50"); - const { text, status } = await this.fetch(url.href, { - headers: { - Authorization: `token ${token}`, - Accept: "application/vnd.github.v3+json", - }, - }); + return this.fetch(url.href, { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }); + }) + ); + for (const { text, status } of requests) { if (status === 401 || status === 403) { - // Token expired or revoked — clear it and fall back await GithubTokenManager.removeToken(this.state.host); return "zen-live-folder-github-token-expired"; } @@ -543,7 +546,7 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { try { const raw = (input.value ?? "").trim(); const parsed = new URL(raw); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + if (parsed.protocol !== "https:") { throw new Error(); } return parsed.origin; @@ -561,9 +564,10 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { } 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 167998ed5f7..02c390e99e8 100644 --- a/src/zen/tests/live-folders/browser_github_live_folder.js +++ b/src/zen/tests/live-folders/browser_github_live_folder.js @@ -181,8 +181,8 @@ add_task(async function test_fetch_network_error() { sandbox.restore(); }); -add_task(async function test_custom_host_url_construction() { - info("should use custom GitHub Enterprise host for fetch URLs"); +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(); @@ -199,64 +199,62 @@ add_task(async function test_custom_host_url_construction() { text: "", }); - await instance.fetchItems(); - - Assert.ok(instance.fetch.calledOnce, "Fetch should be called once"); + const result = await instance.fetchItems(); - const fetchedUrl = new URL(instance.fetch.firstCall.args[0]); + Assert.equal( + result, + "zen-live-folder-github-no-auth", + "GHE without token should return auth error" + ); Assert.ok( - fetchedUrl.href.startsWith("https://github.corp.com/pulls"), - "Should use custom host for PR endpoint" + !instance.fetch.called, + "Should not attempt to fetch without a token for GHE" ); sandbox.restore(); }); -add_task(async function test_custom_host_issue_parsing() { - info("should use custom host when parsing issue URLs"); +add_task(async function test_custom_host_state_construction() { + info("should construct state correctly with custom host"); let sandbox = sinon.createSandbox(); - let instance = getGithubProviderForTest(sandbox, { + + // 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" + ); - const mockHtml = ` - - -
-
org/repo#42
- TestUser -
Test issue
- -
- - - `; - - instance.fetch.resolves({ - text: mockHtml, - status: 200, + // Issues type + let issueInstance = getGithubProviderForTest(sandbox, { + type: "issues", + host: "https://github.corp.com", }); - - const items = await instance.fetchItems(); - - Assert.equal(items.length, 1, "Should find 1 item"); - Assert.equal( - items[0].url, - "https://github.corp.com/issues/42", - "Should use custom host in parsed issue URL" + 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"); + info("should treat non-2xx responses as auth errors for github.com"); let sandbox = sinon.createSandbox(); let instance = getGithubProviderForTest(sandbox, { type: "pull-requests", - host: "https://github.corp.com", }); instance.fetch.resolves({ From b74a77fd995241b5c673b8773502af9730edd19e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 1 Apr 2026 16:54:16 -0400 Subject: [PATCH 5/6] refactor: Simplify GithubLiveFolder after code review - Extract `get #isGitHubEnterprise` getter (replaces 3 inline checks) - Extract `get #hasAnyFilterEnabled` getter (eliminates duplication between fetchItems and fetchItemsViaApi) - Use Promise.allSettled for API queries (preserves partial results when one query fails instead of losing all results) - Sync _hasToken on 401/403 token removal --- .../providers/GithubLiveFolder.sys.mjs | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs index e90c9da595d..e9b89e5a0e4 100644 --- a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs +++ b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs @@ -31,6 +31,18 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { 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 token = await GithubTokenManager.getToken(this.state.host); @@ -40,17 +52,11 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { } // GHE instances require a PAT — HTML scraping won't work without cookies - const isGHE = new URL(this.state.host).hostname !== "github.com"; - if (isGHE) { + if (this.#isGitHubEnterprise) { return "zen-live-folder-github-no-auth"; } - const hasAnyFilterEnabled = - (this.state.options.authorMe ?? false) || - (this.state.options.assignedMe ?? true) || - (this.state.options.reviewRequested ?? false); - - if (!hasAnyFilterEnabled) { + if (!this.#hasAnyFilterEnabled) { return "zen-live-folder-github-no-filter"; } @@ -102,12 +108,7 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { async #fetchItemsViaApi(token) { try { - const hasAnyFilterEnabled = - (this.state.options.authorMe ?? false) || - (this.state.options.assignedMe ?? true) || - (this.state.options.reviewRequested ?? false); - - if (!hasAnyFilterEnabled) { + if (!this.#hasAnyFilterEnabled) { return "zen-live-folder-github-no-filter"; } @@ -117,7 +118,7 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { const combinedItems = new Map(); const combinedActiveRepos = new Set(); - const requests = await Promise.all( + const results = await Promise.allSettled( queries.map(async query => { const url = new URL(`${apiBase}/search/issues`); url.searchParams.set("q", query); @@ -132,14 +133,21 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { }) ); - for (const { text, status } of requests) { + 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)) { - return "zen-live-folder-github-no-auth"; + continue; } try { @@ -170,8 +178,6 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { } this.state.repos = combinedActiveRepos; - - // API authenticated successfully — empty results just means no matching PRs/issues return Array.from(combinedItems.values()); } catch (error) { console.error("Error fetching GitHub API:", error); @@ -454,8 +460,8 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { this.state.type === "pull-requests" ? "/pulls" : "/issues/assigned"; this.state.url = new URL(path, host).href; - // For non-github.com hosts, open the PAT creation page - if (new URL(host).hostname !== "github.com") { + // For GHE hosts, open the PAT creation page + if (this.#isGitHubEnterprise) { this.#openPatCreationPage(host); } @@ -492,8 +498,7 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { switch (errorId) { case "zen-live-folder-github-no-auth": { - const isGHE = new URL(this.state.host).hostname !== "github.com"; - if (isGHE) { + if (this.#isGitHubEnterprise) { // For GHE instances, open the PAT creation page this.#openPatCreationPage(this.state.host); } else { From eba99e6c9b216124a073b48d773457e761904f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 28 Apr 2026 12:19:22 +0100 Subject: [PATCH 6/6] fix: Try cookie-based scraping for GHE before requiring a PAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHE instances using built-in auth (the default for new GHES installs) have long-lived browser sessions just like github.com, so the same cookie-based fallback works there. Instances using SAML/SSO will still fail — those redirect to an IdP login that can't be followed programmatically — and fall through to the existing no-auth path which prompts the user to set a PAT. --- src/zen/live-folders/providers/GithubLiveFolder.sys.mjs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs index e9b89e5a0e4..29a14e943a5 100644 --- a/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs +++ b/src/zen/live-folders/providers/GithubLiveFolder.sys.mjs @@ -51,11 +51,6 @@ export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider { return this.#fetchItemsViaApi(token); } - // 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"; }