merge: reconcile paid hosting with latest main

This commit is contained in:
Thomas Durieux committed 2026-11-02 21:12:29 +00:00
commit ded6f905fd
180 files changed
+21709 -3614

No files matched your search

+302 -42
View File
@@ -2,6 +2,87 @@ import { setupBilling, readBilling } from "./billing.js";
import { reactive, nextTick } from "vue";
import { createTimers, createListeners } from "./state.js";
import { formDraft } from "./drafts.js";
import translations from "../i18n/locale-en.json";
function viewerErrorCode(error) {
let data = error?.data;
if (typeof data === "string") {
try { data = JSON.parse(data); } catch (_) { data = null; }
}
if (typeof data?.error === "string" && Object.prototype.hasOwnProperty.call(translations.ERRORS, data.error)) return data.error;
if (error?.status === -1 || error?.status === 0) return "request_error";
if ([502, 503, 504].includes(error?.status)) return "unreachable";
return "unknown_error";
}
// The mask comes from /api/options; mainController sets it once loaded.
let redactionMask = "XXXX";
export const setRedactionMask = (mask) => { redactionMask = mask || "XXXX"; };
// Matches the replacements the anonymizer writes: MASK and MASK-<n>.
export const redactionPattern = (mask = redactionMask) => {
if (!mask) return null;
const escaped = mask.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(escaped + "(?:-\\d+)?", "g");
};
// Wraps redacted terms under root in <mark class="redaction">, touching text
// nodes only so attributes and URLs stay intact. Mermaid sources are left
// alone (Mermaid parses their text), and so are code blocks Prism will
// rewrite unless root is that highlighted block itself.
export const markRedactionsIn = (root, mask = redactionMask) => {
const pattern = redactionPattern(mask);
if (!pattern || !root || !root.textContent.includes(mask)) return;
const doc = root.ownerDocument;
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
const text = node.nodeValue;
const parent = node.parentElement;
if (!text.includes(mask) || !parent || parent.closest("script, style, mark.redaction, .mermaid")) continue;
const highlighted = parent.closest('code[class*="language-"]');
if (highlighted && !highlighted.contains(root)) continue;
const fragment = doc.createDocumentFragment();
let last = 0;
for (const match of text.matchAll(pattern)) {
fragment.append(text.slice(last, match.index));
const mark = doc.createElement("mark");
mark.className = "redaction";
mark.title = "Redacted by Anonymous GitHub";
mark.textContent = match[0];
fragment.append(mark);
last = match.index + match[0].length;
}
fragment.append(text.slice(last));
node.replaceWith(fragment);
}
};
export const highlightRedactions = (html, mask = redactionMask) => {
if (!html || !mask || !html.includes(mask)) return html;
const doc = new DOMParser().parseFromString("<body>" + html + "</body>", "text/html");
markRedactionsIn(doc.body, mask);
return doc.body.innerHTML;
};
async function copyText(text) {
if (window.navigator.clipboard?.writeText) {
await window.navigator.clipboard.writeText(text);
return;
}
const field = document.createElement("textarea");
field.value = text;
field.setAttribute("readonly", "");
field.style.position = "fixed";
field.style.opacity = "0";
const focused = document.activeElement;
document.body.appendChild(field);
try {
field.select();
if (!document.execCommand?.("copy")) throw Error("Copy unavailable");
} finally { field.remove(); focused?.focus(); }
}
function previewTabKeydown(event) {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
@@ -55,8 +136,11 @@ export const mainController = function (state, http, location, timeout) {
return toast;
};
// Anonymized content that signed-out reviewers browse.
const reviewerRoutes = /^\/(r|repository|pr|gist)\//;
state.path = location.url();
state.paths = location.path().substring(1).split("/");
state.isReviewerRoute = reviewerRoutes.test(location.path());
state.darkMode = function (on) {
localStorage.setItem("darkMode", on);
@@ -80,19 +164,49 @@ export const mainController = function (state, http, location, timeout) {
}
const kofiBtn = document.querySelector("[class*='floatingchat-container-wrap'] [class*='floating-chat-kofi-text-container-wrap']");
if (kofiBtn) {
kofiBtn.style.backgroundColor = on ? "#FAF9F6" : "#1A1815";
kofiBtn.style.color = on ? "#1A1815" : "#FAF9F6";
kofiBtn.style.backgroundColor = on ? "#34312C" : "#1A1815";
kofiBtn.style.color = "#FAF9F6";
}
state.emit("dark-mode", on);
};
state.darkMode(localStorage.getItem("darkMode") == "true");
state.emailPromptOpen = false;
state.emailPromptAddress = "";
state.emailPromptBusy = false;
state.emailPromptError = "";
let emailPromptFocus;
state.respondToEmailPrompt = async (action) => {
if (state.emailPromptBusy) return;
state.emailPromptBusy = true;
state.emailPromptError = "";
try {
const res = await http.post("/github/connections/email", { action, email: state.emailPromptAddress },
{ headers: { "X-CSRF-Token": state.user.emailPromptCSRF } });
if (action === "save") state.user.notificationEmail = res.data.notificationEmail;
state.emailPromptOpen = false;
state.user.showEmailPrompt = false;
emailPromptFocus?.focus();
} catch (error) { state.emailPromptError = error.data?.error || "Unable to save your preference. Please try again."; }
finally { state.emailPromptBusy = false; }
};
function getUser() {
http.get("/api/user").then(
(res) => {
if (res) state.user = res.data;
if (state.user?.username) readBilling(http).then(res => updateHeaderBilling(res.data)).catch(() => {});
if (state.user?.emailNotificationsEnabled && state.user.showEmailPrompt && !state.user.notificationEmail) {
emailPromptFocus = document.activeElement;
state.emailPromptOpen = true;
timeout(() => {
const dialog = document.getElementById("email-prompt");
if (dialog?.showModal) dialog.showModal();
else dialog?.setAttribute("open", "");
document.getElementById("email-prompt-address")?.focus();
}, 0);
}
},
() => {
state.user = null;
@@ -105,6 +219,8 @@ export const mainController = function (state, http, location, timeout) {
http.get("/api/options").then(
(res) => {
if (res) state.site_options = res.data;
setRedactionMask(res?.data?.ANONYMIZATION_MASK);
state.emit("site-options");
},
() => {
state.site_options = null;
@@ -131,6 +247,7 @@ export const mainController = function (state, http, location, timeout) {
}
state.path = location.url();
state.paths = location.path().substring(1).split("/");
state.isReviewerRoute = reviewerRoutes.test(location.path());
}
state.on("routeChange", changedUrl);
@@ -191,6 +308,42 @@ export const profileController = function (state, http, translate, timeout, quot
state.message = null;
state.error = null;
state.accountEmail = "";
state.emailLoading = true;
state.emailCSRFReady = false;
state.emailSaving = false;
state.emailSaved = false;
state.emailError = "";
let emailCSRF = "";
state.loadAccountEmail = async () => {
state.emailLoading = true;
state.emailError = "";
try {
const res = await http.get("/github/connections/email");
state.accountEmail = res.data.notificationEmail || "";
emailCSRF = res.data.csrf;
state.emailCSRFReady = !!emailCSRF;
} catch {
state.emailError = "Unable to load your email address. Please try again.";
} finally { state.emailLoading = false; }
};
state.saveAccountEmail = async () => {
if (state.emailSaving || state.emailLoading || !emailCSRF) return;
state.emailSaving = true;
state.emailSaved = false;
state.emailError = "";
try {
const res = await http.post("/github/connections/email", { email: state.accountEmail },
{ headers: { "X-CSRF-Token": emailCSRF } });
state.accountEmail = res.data.notificationEmail;
if (state.user) state.user.notificationEmail = res.data.notificationEmail;
state.emailSaved = true;
} catch {
state.emailError = "Unable to save your email address. Check the address and try again.";
} finally { state.emailSaving = false; }
};
state.loadAccountEmail();
quotaService.load().then((quota) => {
state.quota = quota;
}, console.error);
@@ -236,11 +389,13 @@ export const profileController = function (state, http, translate, timeout, quot
(error) => {
state.saving = false;
const code = error && error.data && error.data.error;
const key = "ERRORS." + code;
translate(key).then((translation) => {
state.error = translation && translation !== key ? translation : "Unable to save your defaults. Please try again.";
const fallback = "Unable to save your defaults. Please try again.";
// Unknown codes translate to the generic unknown_error message;
// this page has a more specific one.
Promise.all([translate("ERRORS." + code), translate("ERRORS.unknown_error")]).then(([translation, unknown]) => {
state.error = code && translation !== unknown ? translation : fallback;
}, () => {
state.error = "Unable to save your defaults. Please try again.";
state.error = fallback;
});
}
);
@@ -544,21 +699,7 @@ export const unifiedDashboardController = function (state, http, location, promi
const link = new URL(item._viewUrl, window.location.origin).href;
state.copyingId = item._type + ":" + item._id;
try {
if (window.navigator.clipboard?.writeText) {
await window.navigator.clipboard.writeText(link);
} else {
const field = document.createElement("textarea");
field.value = link;
field.setAttribute("readonly", "");
field.style.position = "fixed";
field.style.opacity = "0";
const focused = document.activeElement;
document.body.appendChild(field);
try {
field.select();
if (!document.execCommand?.("copy")) throw Error("Copy unavailable");
} finally { field.remove(); focused?.focus(); }
}
await copyText(link);
state.addToast({ title: "Link copied", body: "The anonymous link is ready to share." });
} catch {
state.addToast({ title: "Copy this anonymous link", body: link });
@@ -1034,6 +1175,19 @@ export const statusController = function (state, http, params) {
state.progress = 0;
state.rateLimitResetAt = 0;
state.rateLimitCountdown = "";
state.shareUrl = (window.location?.origin || "") + "/r/" + encodeURIComponent(state.repoId) + "/";
state.copied = false;
let copiedTimer = null;
state.copyShareUrl = async () => {
try {
await copyText(state.shareUrl);
state.copied = true;
if (copiedTimer) timers.timeout.cancel(copiedTimer);
copiedTimer = timers.timeout(() => { state.copied = false; }, 2000);
} catch {
state.addToast?.({ title: "Copy this anonymous link", body: state.shareUrl });
}
};
var countdownTimer = null;
let pollTimer = null;
@@ -1324,6 +1478,33 @@ export const anonymizeController = function (state, http, html, params, location
state.html_readme = "";
state.isUpdate = false;
state.previousConfigurations = [];
state.previousConfigurationId = "";
let previousRepositories;
const normalizedRepositoryName = name => String(name || "").replace(/\/$/, "").replace(/\.git$/i, "").toLowerCase();
async function findPreviousConfigurations() {
if (state.isUpdate || params.repoId || params.pullRequestId || params.gistId) return;
const request = { generation: sourceGeneration, url: state.sourceUrl.trim(), connection: state.githubConnection };
try {
// This endpoint only returns repositories the signed-in user can manage.
previousRepositories ||= http.get("/api/user/anonymized_repositories").then(res => res.data);
const repositories = await previousRepositories;
if (!isCurrentSource(request)) return;
const name = normalizedRepositoryName(parseRepoFullName(request.url));
state.previousConfigurations = repositories.filter(repo => repo.repoId && repo.options
&& normalizedRepositoryName(repo.source?.fullName) === name)
.sort((a, b) => (Date.parse(b.anonymizeDate) || 0) - (Date.parse(a.anonymizeDate) || 0));
state.previousConfigurationId = state.previousConfigurations[0]?.repoId || "";
} catch (_) {
// A suggestion failure must not prevent creating a repository. Retry on the next URL selection.
previousRepositories = undefined;
}
}
state.existingConfigurationUrl = () => {
const previous = state.previousConfigurations.find(repo => repo.repoId === state.previousConfigurationId);
return previous ? "/anonymize/" + encodeURIComponent(previous.repoId) : null;
};
function getDefault(cb) {
http.get("/api/user/default").then((res) => {
const data = res.data;
@@ -1360,6 +1541,38 @@ export const anonymizeController = function (state, http, html, params, location
return null;
}
state.accessRepairAllowed = false;
state.repairingGitHubAccess = false;
state.accessRepairMessage = "";
state.repairGitHubAccess = async () => {
if (!state.accessRepairAllowed || state.repairingGitHubAccess || !state.githubConnections?.appEnabled || !params.repoId || state.role !== "owner" ||
state.githubConnection !== "github-app" || parseRepoFullName(state.sourceUrl) !== state._originalFullName) return;
state.repairingGitHubAccess = true;
state.accessRepairMessage = "";
const request = beginSourceRequest();
try {
const connections = await http.get("/github/connections");
state.githubConnections = connections.data;
if (!connections.data.appEnabled) return;
const repaired = await http.post("/github/connections/migrate", { type: "repository", id: params.repoId, connection: "github-app" },
{ headers: { "X-CSRF-Token": connections.data.csrf } });
if (!isCurrentSource(request)) return;
state.error = null;
if (repaired.data.fullName) {
state._originalFullName = repaired.data.fullName;
state.source.fullName = repaired.data.fullName;
state.sourceUrl = "https://github.com/" + repaired.data.fullName;
}
state.accessRepairMessage = "GitHub access repaired. Your anonymous URL and published commit are unchanged.";
await state.urlSelected(true);
} catch (_) {
if (isCurrentSource(request)) state.accessRepairMessage = "Unable to repair GitHub access. Check the App's repository permissions in Manage access, then retry. Your published snapshot has not been replaced.";
} finally {
state.repairingGitHubAccess = false;
finishSourceRequest(request);
}
};
state.reconnectingSource = false;
state.reconnectRepositoryId = undefined;
state.reconnectSource = async () => {
@@ -1438,6 +1651,7 @@ export const anonymizeController = function (state, http, html, params, location
state.repoId = params.repoId;
http.get("/api/repo/" + state.repoId).then(
async (res) => {
state.accessRepairAllowed = !["removed", "archived", "preparing", "queue", "download", "removing", "expiring"].includes(res.data.status);
state.githubConnection = res.data.connection || "oauth";
state.sourceUrl = "https://github.com/" + res.data.source.fullName;
state._originalFullName = res.data.source.fullName;
@@ -1534,6 +1748,8 @@ export const anonymizeController = function (state, http, html, params, location
// Keep the same input focused until a complete GitHub URL can be loaded.
state.urlSelected = async (preserveDraft = false) => {
const generation = ++sourceGeneration;
state.previousConfigurations = [];
state.previousConfigurationId = "";
let o;
try { o = parseGithubUrl(state.sourceUrl.trim()); }
catch (_) {
@@ -1589,6 +1805,7 @@ export const anonymizeController = function (state, http, html, params, location
await getPrDetails();
} else {
state.detectedType = "repo";
void findPreviousConfigurations();
await Promise.all([getRepoDetails(), getReadme()]);
anonymizeReadme();
}
@@ -2279,6 +2496,11 @@ export const anonymizeController = function (state, http, html, params, location
export const exploreController = function (state, http, location, params, html, promises) {
const timers = createTimers();
// Prism rewrites fenced code blocks, so mark redactions once it is done.
if (window.Prism?.hooks && !window.Prism.redactionHook) {
window.Prism.hooks.add("complete", (env) => markRedactionsIn(env.element));
window.Prism.redactionHook = true;
}
const listen = createListeners();
state.on("dark-mode", (event, on) => {
if (!state.aceOption) return;
@@ -2510,7 +2732,7 @@ export const exploreController = function (state, http, location, params, html,
}, function (err) {
if (destroyed || repoId !== state.repoId) return [];
state.type = "error";
state.content = (err && err.data && err.data.error) || "unknown_error";
state.content = viewerErrorCode(err);
state.files = [];
});
};
@@ -2580,7 +2802,7 @@ export const exploreController = function (state, http, location, params, html,
timers.timeout(function () { getOptions(callback); }, 3000);
} else {
state.type = "error";
state.content = data.error;
state.content = viewerErrorCode(err);
}
}
);
@@ -2705,23 +2927,7 @@ export const exploreController = function (state, http, location, params, html,
(err) => {
if (destroyed || generation !== contentGeneration) return;
state.type = "error";
state.content = "unknown_error";
try {
err.data = JSON.parse(err.data);
if (err.data.error) {
state.content = err.data.error;
} else {
state.content = err.data;
}
} catch (ignore) {
console.log(err);
if (err.status == -1) {
state.content = "request_error";
} else if (err.status == 502) {
// cloudflare error
state.content = "unreachable";
}
}
state.content = viewerErrorCode(err);
}
);
}
@@ -2765,7 +2971,7 @@ export const exploreController = function (state, http, location, params, html,
theme: "chrome",
useSoftTab: true,
tabSize: 2,
fontSize: 15,
fontSize: 13,
keyBinding: "vscode",
fullLineSelection: true,
highlightActiveLine: false,
@@ -2870,9 +3076,43 @@ export const exploreController = function (state, http, location, params, html,
);
_editor.session.setUseSoftTabs(state.aceOption.useSoftTab);
_editor.session.setTabSize(state.aceOption.tabSize);
// Phones can't scroll long lines comfortably, so wrap them there,
// following the viewport when a tablet rotates or a window resizes.
const narrow = window.matchMedia("(max-width: 991px)");
const applyWrap = () => _editor.session.setUseWrapMode(state.aceOption.useWrapMode && narrow.matches);
applyWrap();
narrow.addEventListener?.("change", applyWrap);
_editor.setBehavioursEnabled(state.aceOption.enableBehaviours);
_editor.setFadeFoldWidgets(state.aceOption.fadeFoldWidgets);
return removeHashListener;
// Outline redacted terms (XXXX-1, ...) so reviewers can tell a
// replacement from the author's own text.
let redactionMarkers = [];
const markRedactions = () => {
redactionMarkers.forEach((id) => _editor.session.removeMarker(id));
redactionMarkers = [];
const pattern = redactionPattern();
if (!pattern) return;
const lines = _editor.session.getDocument().getAllLines();
lines.forEach((line, row) => {
for (const match of line.matchAll(pattern)) {
redactionMarkers.push(_editor.session.addMarker(
new Range(row, match.index, row, match.index + match[0].length),
"ace_redaction",
"text"
));
}
});
};
markRedactions();
_editor.session.on("change", () => timers.timeout(markRedactions, 0));
// The mask may arrive after the editor mounts.
const offOptions = state.on("site-options", markRedactions);
return () => {
removeHashListener();
offOptions();
narrow.removeEventListener?.("change", applyWrap);
};
},
};
if (state.isDarkMode) {
@@ -2892,6 +3132,10 @@ export const exploreController = function (state, http, location, params, html,
getContent(state.filePath, state.file);
}
state.isSourceAccessError = () => ["repo_not_found", "token_expired", "github_app_access_required",
"github_app_reconnect_required", "repo_saml_enforcement", "repo_access_limited"].includes(state.content);
state.retryContent = () => init();
function init() {
contentGeneration++;
state.files = [];
@@ -3319,11 +3563,27 @@ export const conferenceController = function (state, http, location, params) {
export const connectionsController = function (state, http) {
state.notificationEmail = "";
state.emailSaved = false;
state.saveNotificationEmail = async () => {
state.busy = true;
state.emailSaved = false;
state.connectionError = "";
try {
const res = await http.post("/github/connections/email", { email: state.notificationEmail },
{ headers: { "X-CSRF-Token": state.connections.csrf } });
state.notificationEmail = res.data.notificationEmail;
if (state.user) state.user.notificationEmail = res.data.notificationEmail;
state.emailSaved = true;
} catch (error) { state.connectionError = error.data?.error || "Unable to save email address."; }
finally { state.busy = false; }
};
state.connections = null;
state.connectionError = "";
state.busy = false;
state.loadConnections = () => http.get("/github/connections").then(res => {
state.connections = res.data;
state.notificationEmail = res.data.notificationEmail || "";
}).catch(error => { state.connectionError = error.data?.error || "Unable to load connections."; });
state.changeConnection = async (resource, connection, preview) => {
state.busy = true;