mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-19 14:17:20 +02:00
Add isolated artifact owner consent page
This commit is contained in:
1 parent
7b60979c96
commit
e4334bc9a7
10 files changed
+443
-3
No files matched your search
@@ -0,0 +1,24 @@
|
||||
<section class="container py-4" style="max-width: 48rem; overflow-wrap: anywhere">
|
||||
<a href="/">Anonymous GitHub</a>
|
||||
<h1 class="mt-3">Approve artifact access</h1>
|
||||
<p>This records your approval as the repository owner or an authorized coauthor.</p>
|
||||
<p v-if="!user?.username">Sign in first, then reopen the artifact link from your submission. <a href="/signin">Sign in</a></p>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<p v-if="saved" role="status">Consent recorded. Artifact linking is not complete. Return to your submission to check its status.</p>
|
||||
<form v-if="user?.username && !saved" @submit.prevent="loadPreview">
|
||||
<label for="review-repository">Anonymous repository ID</label>
|
||||
<input id="review-repository" class="form-control" v-model="repositoryId" maxlength="128" autocomplete="off" :disabled="busy || uncertain" aria-describedby="review-repository-help">
|
||||
<p id="review-repository-help">Use the ID after /r/ in the anonymous repository URL.</p>
|
||||
<button type="submit" class="btn btn-primary" :disabled="busy || uncertain">Preview access and retention</button>
|
||||
</form>
|
||||
<section v-if="preview && !saved" class="mt-4" aria-labelledby="review-policy-title">
|
||||
<h2 id="review-policy-title">{{ preview.repositoryName }}</h2>
|
||||
<p>Access: {{ preview.policy.access === 'anonymous-link' ? 'Anyone with the anonymous link' : 'Restricted review access' }}.</p>
|
||||
<p>Retain until {{ preview.policy.retainUntil }}. Policy version {{ preview.policy.version }}.</p>
|
||||
<p v-if="expired" role="alert">This preview expired. Open a new artifact link from your submission.</p>
|
||||
<div><label><input type="checkbox" v-model="acceptAccess" :disabled="busy || uncertain || expired"> I approve the access policy above.</label></div>
|
||||
<div><label><input type="checkbox" v-model="acceptRetention" :disabled="busy || uncertain || expired"> I approve retention until the date above.</label></div>
|
||||
<button class="btn btn-primary" type="button" @click="confirmConsent" :disabled="busy || expired || !acceptAccess || !acceptRetention">{{ uncertain ? 'Retry confirmation' : 'Confirm consent' }}</button>
|
||||
</section>
|
||||
<p v-if="busy" role="status">Waiting for the server…</p>
|
||||
</section>
|
||||
+11
-3
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -30,6 +31,9 @@ export function safeUrl(value) {
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -50,7 +54,7 @@ export function mountApplication(target = "#app", options = {}) {
|
||||
const value = router.currentRoute.value.params[key];
|
||||
return Array.isArray(value) ? value.join("/") : value;
|
||||
} });
|
||||
return { http, ...timers, location, params, window, promises, html: { trustAsHtml: value => value }, translate: (key, params) => Promise.resolve(translate(key, params)), quotaService: createQuotaService(http) };
|
||||
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 {
|
||||
@@ -92,7 +96,7 @@ export function mountApplication(target = "#app", options = {}) {
|
||||
const href = anchor.getAttribute("href");
|
||||
if (href.startsWith("#")) return;
|
||||
const url = new URL(anchor.href, window.location.href);
|
||||
if (url.origin !== window.location.origin || serverPaths.test(url.pathname)) return;
|
||||
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");
|
||||
@@ -100,7 +104,7 @@ export function mountApplication(target = "#app", options = {}) {
|
||||
document.addEventListener("click", navigate);
|
||||
onBeforeUnmount(() => document.removeEventListener("click", navigate));
|
||||
return () => [
|
||||
h("header", { class: "app-header" }, templates["partials/header.htm"](root, headerCache)),
|
||||
isolatedConsent ? null : h("header", { class: "app-header" }, 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,
|
||||
})),
|
||||
@@ -123,6 +127,10 @@ export function mountApplication(target = "#app", options = {}) {
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Read once, before the router or application requests can observe the fragment.
|
||||
export function takeReviewFragment(browser) {
|
||||
const fragment = browser.location.hash.slice(1);
|
||||
browser.history.replaceState(null, "", "/review-link");
|
||||
if (fragment.length > 2048) return null;
|
||||
const values = new URLSearchParams(fragment);
|
||||
if ([...values.keys()].length !== 3 || [...values.keys()].some(key => !["clientId", "intentId", "token"].includes(key))) return null;
|
||||
const clientId = values.get("clientId"), intentId = values.get("intentId"), token = values.get("token");
|
||||
if (!/^[a-f0-9]{32}$/.test(clientId || "") || !/^[a-f0-9]{32}$/.test(intentId || "") || !/^[a-f0-9]{64}$/.test(token || "")) return null;
|
||||
return { contract: "4open.artifacts/1", clientId, intentId, token };
|
||||
}
|
||||
|
||||
export function reviewConsentController(state, services) {
|
||||
const { http, promises, window: browser } = services;
|
||||
let handoff = services.takeReviewHandoff();
|
||||
let ticket, csrf, confirmId, consumeId, identity;
|
||||
let active = true, expiryTimer;
|
||||
const stop = promises.defer();
|
||||
const requestId = () => [...browser.crypto.getRandomValues(new Uint8Array(16))].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
Object.assign(state, { repositoryId: "", busy: false, preview: null, acceptAccess: false, acceptRetention: false, uncertain: false, saved: false, expired: false, error: handoff ? "" : "Open a new artifact link from your submission. This link is missing or invalid." });
|
||||
const current = () => active && state.user?.username === identity;
|
||||
function dispose() {
|
||||
active = false; handoff = ticket = csrf = undefined;
|
||||
stop.resolve(); browser.clearTimeout(expiryTimer);
|
||||
}
|
||||
state.on("dispose", dispose);
|
||||
state.on("routeLeave", dispose);
|
||||
state.watch(() => state.user?.username, username => {
|
||||
if (!identity && username) identity = username;
|
||||
else if (identity && username !== identity) {
|
||||
dispose(); state.preview = null; state.busy = false;
|
||||
state.error = "Your account changed. Open a new artifact link from your submission.";
|
||||
}
|
||||
});
|
||||
const options = () => ({ timeout: stop.promise, headers: { "Content-Type": "application/json", "X-Review-CSRF": csrf } });
|
||||
const message = error => error?.status === 404 ? "Artifact linking is not available on this server." : error?.status === 401 ? "Sign in, then open a new artifact link from your submission." : error?.status === 403 ? "This account cannot approve this repository or this link." : "The request could not be completed. You can retry from this page.";
|
||||
state.loadPreview = async () => {
|
||||
if (!active || !handoff || state.busy || state.uncertain || state.saved || !state.user?.username) return;
|
||||
identity = state.user.username;
|
||||
const repositoryId = state.repositoryId.trim();
|
||||
if (!/^[A-Za-z0-9_-]{3,128}$/.test(repositoryId)) { state.error = "Enter the anonymous repository ID from its URL."; return; }
|
||||
state.busy = true; state.error = ""; state.preview = null; ticket = undefined;
|
||||
state.acceptAccess = state.acceptRetention = false; state.expired = false;
|
||||
browser.clearTimeout(expiryTimer);
|
||||
consumeId ||= requestId();
|
||||
try {
|
||||
csrf = (await http.get("/api/review-consent/csrf", { timeout: stop.promise })).data.csrf;
|
||||
if (!current()) return;
|
||||
if (!/^[a-f0-9]{64}$/.test(csrf || "")) throw new Error("invalid_csrf");
|
||||
const result = (await http.post("/api/review-consent/preview", { repositoryId, intent: { ...handoff, requestId: consumeId } }, options())).data;
|
||||
if (!current()) return;
|
||||
if (!result || typeof result.ticket !== "string" || !result.ticket || result.ticket.length > 8192 || typeof result.repositoryName !== "string" || !["restricted-review", "anonymous-link"].includes(result.policy?.access) || !Number.isSafeInteger(result.policy.version) || !Number.isFinite(Date.parse(result.policy.retainUntil)) || !Number.isFinite(Date.parse(result.expiresAt)) || Date.parse(result.expiresAt) <= Date.now() || Date.parse(result.expiresAt) > Date.now() + 600000) throw new Error("invalid_preview");
|
||||
ticket = result.ticket; confirmId = requestId();
|
||||
state.preview = { repositoryName: result.repositoryName, policy: { ...result.policy }, expiresAt: result.expiresAt };
|
||||
expiryTimer = browser.setTimeout(() => { if (active) state.expired = true; }, Math.max(0, Date.parse(result.expiresAt) - Date.now()));
|
||||
} catch (error) { if (current()) state.error = message(error); }
|
||||
finally { if (current()) state.busy = false; }
|
||||
};
|
||||
state.confirmConsent = async () => {
|
||||
if (!current() || state.busy || state.saved || !ticket || !state.acceptAccess || !state.acceptRetention) return;
|
||||
if (Date.parse(state.preview.expiresAt) <= Date.now()) { state.expired = true; return; }
|
||||
state.busy = true; state.error = "";
|
||||
try {
|
||||
const result = (await http.post("/api/review-consent/confirm", { ticket, requestId: confirmId, acceptAccess: true, acceptRetention: true }, options())).data;
|
||||
if (!current()) return;
|
||||
if (result?.requestId !== confirmId || !Number.isFinite(Date.parse(result.confirmedAt))) throw new Error("invalid_receipt");
|
||||
state.saved = true; state.uncertain = false; handoff = ticket = undefined;
|
||||
} catch (error) {
|
||||
if (current()) { state.uncertain = true; state.error = "The confirmation was not verified. Retry with the same approval, or return to your submission to check its status."; }
|
||||
} finally { if (current()) state.busy = false; }
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { reviewConsentController } from "./review-consent.js";
|
||||
import * as pages from "./app.js";
|
||||
import * as admin from "./admin.js";
|
||||
|
||||
export const pageRoutes = [
|
||||
{path: "/review-link", template: "partials/reviewConsent.htm", title: "Artifact consent – Anonymous GitHub", setup: (state, services) => reviewConsentController(state, services)},
|
||||
{path: "/connections", template: "partials/connections.htm", title: "GitHub connections – Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.connectionsController(state, services.http)},
|
||||
{path: "/signin", template: "partials/signin.htm", title: "Sign in – Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.signinController(state, services.http)},
|
||||
{path: "/", template: "partials/home.htm", title: "Anonymous GitHub – Share the code, not the author", preserveExplorer: false, setup: (state, services) => pages.homeController(state, services.http, services.location, services.window, services.timeout)},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { render as reviewConsentTemplate } from "../partials/reviewConsent.htm";
|
||||
import { render as connectionsTemplate } from "../partials/connections.htm";
|
||||
import { render as signinTemplate } from "../partials/signin.htm";
|
||||
import { render as template0 } from "../partials/404.htm";
|
||||
@@ -27,6 +28,7 @@ import { render as template23 } from "../partials/profile.htm";
|
||||
import { render as template24 } from "../partials/pullRequest.htm";
|
||||
import { render as template25 } from "../partials/status.htm";
|
||||
export const templates = {
|
||||
"partials/reviewConsent.htm": reviewConsentTemplate,
|
||||
"partials/connections.htm": connectionsTemplate,
|
||||
"partials/signin.htm": signinTemplate,
|
||||
"partials/404.htm": template0,
|
||||
|
||||
Reference in new issue
Block a user