Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions Client/AccessGate/access-gate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
;(function () {
"use strict"

/**
* AccessGate client: solves the proof-of-work challenge issued by
* Service/access-gate-seed-service.php and stores the resulting token
* in the `cv_gate` cookie (see Module/AccessGate.php for the protocol).
*
* Used by Client/AccessGate/challenge.html (full-page challenge) and by
* XHR/fetch callers (e.g. ContentsViewer feedback) via ensureToken().
*/
var AG = {}

AG.COOKIE_NAME = "cv_gate"

AG.isSupported = function () {
return !!(window.crypto && window.crypto.subtle && window.TextEncoder)
}

AG.hasToken = function () {
return document.cookie.indexOf(AG.COOKIE_NAME + "=") !== -1
}

/**
* Finds a nonce such that sha256(seed + "." + nonce) has `bits` leading
* zero bits. Resolves with the nonce (number). onProgress(attempts) is
* called periodically.
*/
AG.solve = async function (seed, bits, onProgress) {
var encoder = new TextEncoder()
var fullBytes = bits >> 3
var restBits = bits & 7
for (var nonce = 0; ; nonce++) {
var data = encoder.encode(seed + "." + nonce)
var digest = new Uint8Array(await crypto.subtle.digest("SHA-256", data))
var ok = true
for (var i = 0; i < fullBytes; i++) {
if (digest[i] !== 0) { ok = false; break }
}
if (ok && restBits > 0 && (digest[fullBytes] >> (8 - restBits)) !== 0) {
ok = false
}
if (ok) return nonce
if (onProgress && (nonce & 255) === 0) onProgress(nonce)
}
}

AG.setCookie = function (token) {
// seed = v1.{expiry}.{bits}.{mac}; cookie lifetime follows the token expiry.
var expiry = parseInt(token.split(".")[1], 10)
var maxAge = Math.max(60, expiry - Math.floor(Date.now() / 1000))
document.cookie = AG.COOKIE_NAME + "=" + token +
"; path=/; max-age=" + maxAge + "; SameSite=Lax" +
(location.protocol === "https:" ? "; Secure" : "")
return AG.hasToken()
}

/**
* Fetches a seed from `serviceUri` (e.g. CV.vars.serviceUri), solves the
* proof-of-work and stores the cookie.
* Resolves with {ok: true} or {ok: false, reason: string}.
*/
AG.acquireToken = async function (serviceUri, onProgress) {
if (!AG.isSupported()) return { ok: false, reason: "unsupported" }
if (!navigator.cookieEnabled) return { ok: false, reason: "cookie-disabled" }
var response
try {
response = await fetch(serviceUri + "/access-gate-seed-service.php", {
method: "POST",
cache: "no-store",
})
} catch (error) {
return { ok: false, reason: "network" }
}
if (!response.ok) return { ok: false, reason: "seed-failed" }
var body
try {
body = await response.json()
} catch (error) {
return { ok: false, reason: "seed-failed" }
}
if (!body || typeof body.seed !== "string") return { ok: false, reason: "seed-failed" }
var nonce = await AG.solve(body.seed, body.bits, onProgress)
if (!AG.setCookie(body.seed + "." + nonce)) {
return { ok: false, reason: "cookie-disabled" }
}
return { ok: true }
}

/** acquireToken() unless a token cookie is already present. */
AG.ensureToken = async function (serviceUri, onProgress) {
if (AG.hasToken()) return { ok: true }
return AG.acquireToken(serviceUri, onProgress)
}

window.AccessGate = AG
})()
121 changes: 121 additions & 0 deletions Client/AccessGate/challenge.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Checking your browser…</title>
<style>
html { color-scheme: light dark; }
body {
font-family: system-ui, sans-serif;
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 80vh; margin: 0; padding: 1em;
text-align: center;
}
.spinner {
width: 2em; height: 2em; margin-bottom: 1em;
border: 3px solid rgba(128, 128, 128, .3); border-top-color: currentColor;
border-radius: 50%; animation: ag-spin 1s linear infinite;
}
@keyframes ag-spin { to { transform: rotate(360deg); } }
#progress { opacity: .6; font-size: .85em; min-height: 1.2em; }
</style>
</head>
<body>
<div class="spinner" aria-hidden="true"></div>
<p id="msg">ページを確認しています… / Verifying your browser…</p>
<p id="progress"></p>
<noscript><p>JavaScriptとCookieを有効にして再読み込みしてください。<br>Please enable JavaScript and cookies, then reload.</p></noscript>
<script>
;(function () {
"use strict"

// This page is served statically (rewritten under the protected URL by
// .htaccess, or inlined by AccessGate with status 428), so the app root
// is unknown here. Probe candidate roots, shortest first: non-roots
// answer with a cheap static 404, the real root answers the seed.

var msg = document.getElementById("msg")
var progress = document.getElementById("progress")

function fail(text) {
msg.textContent = text
progress.textContent = ""
var spinner = document.querySelector(".spinner")
if (spinner) spinner.style.display = "none"
}

function rootCandidates() {
var segments = location.pathname.split("/").filter(function (s) { return s !== "" })
var candidates = [""]
var prefix = ""
for (var i = 0; i < segments.length - 1 && i < 4; i++) {
prefix += "/" + segments[i]
candidates.push(prefix)
}
return candidates
}

async function findSeedRoot() {
var candidates = rootCandidates()
for (var i = 0; i < candidates.length; i++) {
try {
var response = await fetch(candidates[i] + "/Service/access-gate-seed-service.php", {
method: "POST",
cache: "no-store",
})
if (response.ok) return candidates[i]
} catch (error) { /* try next candidate */ }
}
return null
}

function loadSolver(root) {
return new Promise(function (resolve, reject) {
if (window.AccessGate) { resolve(); return }
var script = document.createElement("script")
script.src = root + "/Client/AccessGate/access-gate.js"
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
}

async function run() {
if (!(window.crypto && window.crypto.subtle && window.TextEncoder)) {
fail("このブラウザは対応していません。 / This browser is not supported.")
return
}
if (!navigator.cookieEnabled) {
fail("Cookieを有効にして再読み込みしてください。 / Please enable cookies and reload.")
return
}
var root = await findSeedRoot()
if (root === null) {
fail("確認サービスに接続できませんでした。しばらくして再読み込みしてください。 / Could not reach the verification service. Please reload later.")
return
}
try {
await loadSolver(root)
} catch (error) {
fail("読み込みに失敗しました。再読み込みしてください。 / Failed to load. Please reload.")
return
}
var result = await window.AccessGate.acquireToken(root + "/Service", function (attempts) {
progress.textContent = attempts + " …"
})
if (result.ok) {
location.reload()
} else if (result.reason === "cookie-disabled") {
fail("Cookieを有効にして再読み込みしてください。 / Please enable cookies and reload.")
} else {
fail("確認に失敗しました。再読み込みしてください。 / Verification failed. Please reload.")
}
}

run()
})()
</script>
</body>
</html>
79 changes: 40 additions & 39 deletions Client/ContentsViewer/ContentsViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -710,30 +710,50 @@
CV.elements.searchResults.appendChild(div)
}

// Sends a FormData to feedback-service with an AccessGate token
// (see Client/AccessGate/access-gate.js), acquiring one first when
// missing (proof-of-work, about a second) and retrying once on 428.
CV.sendFeedbackForm = function (form) {
var send = function (isRetry) {
var xhr = new XMLHttpRequest()
xhr.open("POST", CV.vars.serviceUri + "/feedback-service.php", true)
xhr.onload = function (e) {
if (this.status == 428 && !isRetry && window.AccessGate) {
AccessGate.acquireToken(CV.vars.serviceUri).then(function (result) {
if (result.ok) send(true)
else console.error("AccessGate: " + result.reason)
})
return
}
try {
if (!CV.validateResponse(this)) {
throw "Sorry... Internal Error occured."
}

if (this.parsedResponse.error) {
throw this.parsedResponse.error
}
} catch (err) {
console.error(err)
return
}
}
xhr.send(form)
}

if (window.AccessGate) {
AccessGate.ensureToken(CV.vars.serviceUri).then(function () { send(false) })
} else {
send(false)
}
}

CV.sendRating = function (button) {
var rating = button.getAttribute("data-value")
var form = new FormData()
form.append("cmd", "rate")
form.append("contentPath", CV.vars.contentPath)
form.append("rating", rating)
form.append("otp", CV.vars.otp)

var xhr = new XMLHttpRequest()
xhr.open("POST", CV.vars.serviceUri + "/feedback-service.php", true)
xhr.onload = function (e) {
try {
if (!CV.validateResponse(this)) {
throw "Sorry... Internal Error occured."
}

if (this.parsedResponse.error) {
throw this.parsedResponse.error
}
} catch (err) {
console.error(err)
return
}
}

var survey = document.getElementById("content-survey")
document.querySelector("#content-survey .button-group").style.display = "none"
Expand All @@ -759,7 +779,7 @@
}
button.classList.add("submit-button")
survey.appendChild(button)
xhr.send(form)
CV.sendFeedbackForm(form)
}

CV.sendMessage = function () {
Expand All @@ -768,31 +788,13 @@
form.append("cmd", "message")
form.append("contentPath", CV.vars.contentPath)
form.append("message", message)
form.append("otp", CV.vars.otp)

var xhr = new XMLHttpRequest()
xhr.open("POST", CV.vars.serviceUri + "/feedback-service.php", true)
xhr.onload = function (e) {
try {
if (!CV.validateResponse(this)) {
throw "Sorry... Internal Error occured."
}

if (this.parsedResponse.error) {
throw this.parsedResponse.error
}
} catch (err) {
console.error(err)
return
}
}

var survey = document.getElementById("content-survey")
survey.classList.add("submitted")
document.querySelector("#content-survey .how-improve").style.display = "none"
document.querySelector("#content-survey .any-feedback").style.display = "none"

xhr.send(form)
CV.sendFeedbackForm(form)
}

CV.onClickLayerSelector = function (element, event) {
Expand Down Expand Up @@ -854,7 +856,6 @@
CV.vars.token = (item = document.getElementsByName("token").item(0)) ? item.content : undefined
CV.vars.contentPath = (item = document.getElementsByName("content-path").item(0)) ? item.content : undefined
CV.vars.serviceUri = (item = document.getElementsByName("service-uri").item(0)) ? item.content : undefined
CV.vars.otp = (item = document.getElementsByName("otp").item(0)) ? item.content : undefined

CV.elements = {}
CV.elements.header = document.querySelector("#header")
Expand Down
Loading