mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 08:17:26 +01:00
Backend, scripts, docs and their tests take main's newer code where both sides changed the same lines (the shared blob cache, GitHubStream and markdown fixes landed on main after the Go migration branch was cut). The Dockerfile keeps the Go build from the migration, plus main's owner-alert-protocol label; .gitignore keeps both sides' entries. Frontend keeps the redesign and folds in main's features: Stripe billing in the New anonymization submit bar, GitHub access repair, redaction syntax help, the preview expand toggle, configurable redaction mask, folder counts, reviewer-route header rules, link expiry (now in the viewer's snapshot details) and the explorer error card. main's narrow-only wrap logic is dropped because the redesign wraps on desktop and scrolls sideways on phones. Tests: 1534 passing with SESSION_SECRET set (as in CI). Three main tests were updated for the agreed viewer design (no site header in the viewer, no repository-wide file count). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
8.3 KiB
JavaScript
161 lines
8.3 KiB
JavaScript
import { takeReviewFragment } from "./review-consent.js";
|
||
import { loadLibrary } from "./lazy-assets.js";
|
||
import { createApp, h, watch, provide, inject, onBeforeUnmount } from "vue";
|
||
import { createRouter, createWebHistory, RouterView } from "vue-router";
|
||
import { pageRoutes } from "./routes.js";
|
||
import { templates } from "./templates.js";
|
||
import { render as iconsTemplate } from "../partials/icons.htm";
|
||
import { initializeTemplate } from "./template-state.js";
|
||
import { createPageState, createTimers } from "./state.js";
|
||
import { createHttp, promises } from "./http.js";
|
||
import { mainController, highlightRedactions } from "./app.js";
|
||
import { createQuotaService } from "./quota.js";
|
||
import * as formatters from "./formatters.js";
|
||
import translations from "../i18n/locale-en.json";
|
||
import { components, codeEditor, paperScrollspy } from "./components.js";
|
||
import Tree from "./tree.js";
|
||
import { fieldDirective, formDirective, submitForm } from "./forms.js";
|
||
|
||
const sessionKey = Symbol("session");
|
||
export const serverPaths = /^\/(w|api|github)(\/|$)/;
|
||
function translate(key, params = {}) {
|
||
let message = key?.split(".").reduce((value, part) => value?.[part], translations);
|
||
// Unknown error codes would otherwise surface as the raw "ERRORS.code" key.
|
||
if (message == null && key?.startsWith("ERRORS.")) message = translations.ERRORS.unknown_error;
|
||
return String(message ?? key ?? "").replace(/{{\s*([^}]+?)\s*}}/g, (_, name) => params[name] ?? "");
|
||
}
|
||
const fmt = { ...formatters, translate };
|
||
export function safeUrl(value) {
|
||
if (value == null || value === "") return value;
|
||
try {
|
||
const url = new URL(value, window.location.href);
|
||
return ["http:", "https:", "mailto:", "tel:", "blob:"].includes(url.protocol) ? value : undefined;
|
||
} catch { return undefined; }
|
||
}
|
||
|
||
export function mountApplication(target = "#app", options = {}) {
|
||
const isolatedConsent = !!document.querySelector('meta[name="review-consent-page"][content="1"]');
|
||
let reviewHandoff = isolatedConsent && window.location.pathname === "/review-link" ? takeReviewFragment(window) : null;
|
||
const takeReviewHandoff = () => { const value = reviewHandoff; reviewHandoff = null; return value; };
|
||
// AngularJS links stored the repository route in the fragment. Normalize it
|
||
// before web history reads the URL, replacing the entry so Back still works.
|
||
if (!options.history && window.location.pathname === "/" && /^#!\/r\/[^/?#]+/.test(window.location.hash)) {
|
||
window.history.replaceState(window.history.state, "", window.location.hash.slice(2));
|
||
}
|
||
const events = new Map();
|
||
const http = createHttp(options.fetch);
|
||
let root;
|
||
let router;
|
||
function services() {
|
||
const timers = createTimers();
|
||
const location = {
|
||
url(value) { if (value === undefined) return router.currentRoute.value.fullPath; router.push(value); return location; },
|
||
replace(value) { return router.replace(value); },
|
||
path() { return router.currentRoute.value.path; },
|
||
search() { return router.currentRoute.value.query; },
|
||
};
|
||
const params = new Proxy({}, { get(_, key) {
|
||
const value = router.currentRoute.value.params[key];
|
||
return Array.isArray(value) ? value.join("/") : value;
|
||
} });
|
||
return { takeReviewHandoff, http, ...timers, location, params, window, promises, html: { trustAsHtml: value => value }, translate: (key, params) => Promise.resolve(translate(key, params)), quotaService: createQuotaService(http) };
|
||
}
|
||
function pageComponent(definition) {
|
||
return {
|
||
name: definition.template,
|
||
setup() {
|
||
const state = createPageState(inject(sessionKey), events);
|
||
definition.setup(state, services());
|
||
initializeTemplate(definition.template, state);
|
||
const cache = [];
|
||
return () => templates[definition.template](state, cache);
|
||
},
|
||
};
|
||
}
|
||
router = createRouter({
|
||
history: options.history || createWebHistory(),
|
||
routes: pageRoutes.map(definition => definition.redirect ? definition : ({
|
||
path: definition.path,
|
||
component: pageComponent(definition),
|
||
meta: { title: definition.title, preserveExplorer: definition.preserveExplorer },
|
||
})),
|
||
});
|
||
const app = createApp({
|
||
setup() {
|
||
root = createPageState(null, events);
|
||
root.fmt = fmt;
|
||
root.window = window;
|
||
root.Math = Math;
|
||
root.sanitize = value => DOMPurify.sanitize(value ?? "");
|
||
// Reading site_options re-renders Markdown once the configured mask loads.
|
||
root.highlightRedactions = html => (root.site_options, highlightRedactions(html));
|
||
root.submitForm = submitForm;
|
||
root.safeUrl = safeUrl;
|
||
const context = services();
|
||
mainController(root, context.http, context.location, context.timeout);
|
||
provide(sessionKey, root);
|
||
watch(() => root.title, title => { document.title = title || "Anonymous GitHub"; });
|
||
const headerCache = [];
|
||
const navigate = event => {
|
||
const anchor = event.target.closest?.("a[href]");
|
||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || !anchor || anchor.hasAttribute("download") || (anchor.target && anchor.target !== "_self") || anchor.dataset.toggle) return;
|
||
const href = anchor.getAttribute("href");
|
||
if (href.startsWith("#")) return;
|
||
const url = new URL(anchor.href, window.location.href);
|
||
if (isolatedConsent || /^\/review-link\/?$/i.test(url.pathname) || url.origin !== window.location.origin || serverPaths.test(url.pathname)) return;
|
||
event.preventDefault();
|
||
router.push(url.pathname + url.search + url.hash);
|
||
$("#navbarSupportedContent.show").collapse("hide");
|
||
};
|
||
document.addEventListener("click", navigate);
|
||
onBeforeUnmount(() => document.removeEventListener("click", navigate));
|
||
return () => [
|
||
iconsTemplate(root, []),
|
||
// Repository viewers are a full-height app shell; the explorer carries brand and links itself.
|
||
isolatedConsent || /^\/(r|repository)\//.test(root.path || "") ? null : h("header", { class: ["app-header", { "is-contained": root.path === "/" || root.path === "/faq" }] }, templates["partials/header.htm"](root, headerCache)),
|
||
h("main", { class: "app-view align-items-stretch w-100" }, h(RouterView, null, {
|
||
default: ({ Component, route }) => Component ? h(Component, { key: route.meta.preserveExplorer ? route.matched[0]?.path : route.path }) : null,
|
||
})),
|
||
h("div", { class: "position-fixed p-3", style: { zIndex: 999999999, right: 0, bottom: 0 } }, root.toasts.map((toast, i) => h("div", { class: "toast show", role: "alert", "aria-live": "assertive", "aria-atomic": "true", key: i }, [
|
||
h("div", { class: "toast-header" }, [h("strong", { class: "mr-auto" }, toast.title), h("button", { type: "button", class: "ml-2 mb-1 close", "aria-label": "Close", onClick: () => root.removeToast(toast) }, "×")]),
|
||
h("div", { class: "toast-body" }, toast.body),
|
||
]))),
|
||
];
|
||
},
|
||
});
|
||
for (const [name, component] of Object.entries(components)) app.component(name, component);
|
||
app.component("Tree", Tree);
|
||
app.component("PartialView", {
|
||
props: ["name", "state"],
|
||
setup(props) { const cache = []; return () => templates[props.name](props.state, cache); },
|
||
});
|
||
app.directive("field", fieldDirective);
|
||
app.directive("form", formDirective);
|
||
app.directive("code-editor", codeEditor);
|
||
app.directive("paper-scrollspy", paperScrollspy);
|
||
app.use(router);
|
||
router.beforeEach(async to => {
|
||
if ((to.path === "/review-link" && !isolatedConsent) || (isolatedConsent && to.path !== "/review-link")) {
|
||
window.location.assign(to.fullPath);
|
||
return false;
|
||
}
|
||
root?.emit("routeLeave");
|
||
if (/^\/(r|repository|anonymize|pull-request-anonymize|gist-anonymize|pr|gist)(\/|$)/.test(to.path)) {
|
||
await loadLibrary("markdown");
|
||
}
|
||
if (/^\/(r|repository)\//.test(to.path)) await loadLibrary("org");
|
||
});
|
||
router.afterEach(to => {
|
||
if (!root) return;
|
||
root.title = to.meta.title;
|
||
root.emit("routeChange", { title: to.meta.title });
|
||
root.emit("routeUpdate", { title: to.meta.title });
|
||
const view = document.querySelector(".app-view");
|
||
if (!to.meta.preserveExplorer && view) view.scrollTop = 0;
|
||
});
|
||
app.mount(target);
|
||
return { app, router, state: root };
|
||
}
|
||
|
||
if (document.querySelector("#app")) window.anonymousApp = mountApplication();
|