1/** 2 * vim:set softtabstop=4 shiftwidth=4 expandtab: 3 * 4 * LICENSE: GNU Affero General Public License, version 3 (AGPL-3.0-or-later) 5 * Copyright Ampache.org, 2001-2026 6 * 7 * This program is free software: you can redistribute it and/or modify 8 * it under the terms of the GNU Affero General Public License as published by 9 * the Free Software Foundation, either version 3 of the License, or 10 * (at your option) any later version. 11 * 12 * This program is distributed in the hope that it will be useful, 13 * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 * GNU Affero General Public License for more details. 16 * 17 * You should have received a copy of the GNU Affero General Public License 18 * along with this program. If not, see <https://www.gnu.org/licenses/>. 19 * 20 */ 21 22/** 23 * Solves the proof-of-work challenge rendered by Ampache\Gui\Pow\PowWidgetView. 24 * 25 * The search itself lives in pow.worker.js so the page stays responsive, which is what lets a 26 * registration form be filled in while the answer is being found: by the time the visitor reaches 27 * the submit button the work is usually already done and they never waited for it. 28 * 29 * Where the page gives it a canvas, the wait is drawn with the web player's visualiser, fed by the 30 * search instead of by an audio analyser. Digests supply the spectrum and every new best hash sets 31 * off a burst, so the display slows down and brightens as the answer gets closer. 32 */ 33(function () { 34 'use strict'; 35 36 var widget = document.getElementById('pow-widget'); 37 if (!widget) { 38 return; 39 } 40 41 var status = document.getElementById('pow-status'); 42 var nonceField = document.getElementById('pow_nonce'); 43 44 // Strings come translated from widget.phtml; xgettext never scans .js files. 45 function text(name, fallback) { 46 var value = widget.dataset['text' + name]; 47 48 return (typeof value === 'string' && value !== '') ? value : fallback; 49 } 50 var form = widget.closest('form'); 51 var autoSubmit = form !== null && form.dataset.powAutosubmit === '1'; 52 var sink = document.getElementById('pow-sink'); 53 var returnLink = document.getElementById('pow-return'); 54 var submits = form 55 ? form.querySelectorAll('input[type=submit], button[type=submit], button:not([type])') 56 : []; 57 58 function say(message) { 59 if (status) { 60 status.textContent = message; 61 } 62 } 63 64 function setSubmitEnabled(enabled) { 65 for (var index = 0; index < submits.length; index++) { 66 submits[index].disabled = !enabled; 67 } 68 } 69 70 if (typeof Worker === 'undefined') { 71 say(text('Unsupported', 'Your browser is too old to pass this check.')); 72 73 return; 74 } 75 76 setSubmitEnabled(false); 77 78 /** 79 * The web player's visualiser, driven by the search. 80 * 81 * `spectrum` holds bytes lifted straight out of recent digests, which is uniform noise, so an 82 * envelope shapes it into something that reads as a spectrum. `beat` is set by a new best hash 83 * and `progress` swells the whole thing as the search advances. 84 */ 85 var visualizer = (function () { 86 var canvas = document.getElementById('pow-canvas'); 87 var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 88 89 if (!canvas || !canvas.getContext || reduceMotion) { 90 return null; 91 } 92 93 var BINS = 128; 94 var spectrum = new Uint8Array(BINS); 95 var writeHead = 0; 96 var tick = 0; 97 var beat = 0; 98 var beatPrevious = 0; 99 var progress = 0; 100 var frame = null; 101 var particles = []; 102 var seeds = []; 103 104 for (var seedIndex = 0; seedIndex < 160; seedIndex++) { 105 seeds.push(Math.random()); 106 } 107 108 /** 109 * Digest words arrive as unsigned 32 bit integers; each contributes four bytes to the 110 * rolling spectrum, which is what makes the bars shimmer with the actual search. 111 */ 112 function feed(words, bestBits, ratio) { 113 for (var index = 0; index < words.length; index++) { 114 for (var shift = 24; shift >= 0; shift -= 8) { 115 spectrum[writeHead % BINS] = (words[index] >>> shift) & 255; 116 writeHead++; 117 } 118 } 119 120 if (bestBits > beatPrevious) { 121 // Each extra zero bit is twice as rare as the last, so later bursts are both scarcer 122 // and stronger, and the display visibly tightens as the answer approaches. 123 beat = Math.min(1, 0.45 + (bestBits - beatPrevious) * 0.2); 124 beatPrevious = bestBits; 125 } 126 127 progress = Math.max(progress, Math.min(1, ratio)); 128 } 129 130 function burst(context, centreX, centreY, radius, strength, hueShift) { 131 var count = 6 + Math.floor(strength * 14); 132 133 for (var index = 0; index < count; index++) { 134 var angle = (index / count) * Math.PI * 2 + Math.random() * 0.4; 135 var speed = (2 + Math.random() * 3) * (1 + strength); 136 137 particles.push({
138 x: centreX + Math.cos(angle) * radius, 139 y: centreY + Math.sin(angle) * radius, 140 vx: Math.cos(angle) * speed, 141 vy: Math.sin(angle) * speed, 142 life: 1, 143 hue: Math.round(150 + Math.random() * 160 + hueShift) % 360 144 }); 145 } 146 } 147 148 function draw() { 149 var context = canvas.getContext('2d'); 150 var ratio = window.devicePixelRatio || 1; 151 var width = canvas.clientWidth || 300; 152 var height = canvas.clientHeight || 300; 153 154 if (canvas.width !== Math.round(width * ratio)) { 155 canvas.width = Math.round(width * ratio); 156 canvas.height = Math.round(height * ratio); 157 } 158 159 context.setTransform(ratio, 0, 0, ratio, 0, 0); 160 context.clearRect(0, 0, width, height); 161 162 tick += 0.004; 163 164 // Idle motion keeps the shape alive between worker messages, which arrive far less often 165 // than frames do. 166 var energy = 0.35 + progress * 0.45 + beat * 0.4; 167 var loudness = 0; 168 var values = new Float32Array(BINS); 169 170 for (var bin = 0; bin < BINS; bin++) { 171 var falloff = Math.pow(1 - bin / BINS, 0.7); 172 var wave = 0.6 + 0.4 * Math.sin(bin * 0.35 + tick * 55); 173 var value = (spectrum[bin] / 255) * falloff * wave * energy; 174 175 values[bin] = value; 176 loudness += value; 177 } 178 179 var average = loudness / BINS; 180 var bass = Math.min(1, 0.2 + progress * 0.3 + beat * 0.8); 181 182 var centreX = width / 2; 183 var centreY = height / 2; 184 var minDimension = Math.min(width, height); 185 var baseRadius = minDimension * 0.11 * (1 + average * 0.9); 186 var maxLength = minDimension * 0.46; 187 var spokes = 120; 188 var hueShift = (tick * 40) % 360; 189 190 context.lineCap = 'round'; 191 context.globalCompositeOperation = 'lighter'; 192 193 // Soft radial core glow that swells with the bass. 194 var coreRadius = baseRadius * (2.4 + bass * 2.2); 195 var core = context.createRadialGradient(centreX, centreY, 0, centreX, centreY, coreRadius); 196 core.addColorStop(0, 'hsla(' + Math.round(190 + hueShift) % 360 + ', 95%, 65%, ' + (0.35 + bass * 0.5) + ')'); 197 core.addColorStop(1, 'hsla(' + Math.round(190 + hueShift) % 360 + ', 95%, 55%, 0)'); 198 context.fillStyle = core; 199 context.beginPath(); 200 context.arc(centreX, centreY, coreRadius, 0, Math.PI * 2); 201 context.fill(); 202 203 context.shadowBlur = 8 + average * 22; 204 205 for (var spoke = 0; spoke < spokes; spoke++) { 206 var reading = values[Math.floor(spoke * BINS / spokes)]; 207 var seed = seeds[spoke % seeds.length]; 208 var angle = (spoke / spokes) * Math.PI * 2 + tick + seed * 0.4; 209 var length = baseRadius + reading * maxLength * (0.5 + seed * 0.9); 210 var hue = Math.round(150 + seed * 130 + reading * 50 + hueShift) % 360; 211 var colour = 'hsl(' + hue + ', 90%, ' + Math.round(50 + reading * 30) + '%)'; 212 var tipX = centreX + Math.cos(angle) * length; 213 var tipY = centreY + Math.sin(angle) * length; 214 215 context.strokeStyle = colour; 216 context.shadowColor = colour; 217 context.lineWidth = 3 + reading * 9; 218 context.beginPath(); 219 context.moveTo(centreX + Math.cos(angle) * baseRadius, centreY + Math.sin(angle) * baseRadius); 220 context.lineTo(tipX, tipY); 221 context.stroke(); 222 223 if (reading > 0.35) { 224 context.fillStyle = 'hsl(' + hue + ', 95%, 75%)'; 225 context.beginPath(); 226 context.arc(tipX, tipY, 2 + reading * 4, 0, Math.PI * 2); 227 context.fill(); 228 } 229 } 230 231 context.shadowBlur = 0; 232 233 // Reactive ring pulsing with loudness. 234 context.beginPath(); 235 context.arc(centreX, centreY, baseRadius * (1.15 + average * 0.7), 0, Math.PI * 2); 236 context.strokeStyle = 'hsla(' + Math.round(190 + hueShift) % 360 + ', 90%, 70%, ' + (0.3 + average * 0.5) + ')'; 237 context.lineWidth = 2 + average * 6; 238 context.stroke(); 239 240 if (beat > 0.35) { 241 burst(context, centreX, centreY, baseRadius, beat, hueShift); 242 } 243 244 beat *= 0.9; 245 246 for (var index = particles.length - 1; index >= 0; index--) { 247 var particle = particles[index]; 248 249 particle.x += particle.vx; 250 particle.y += particle.vy; 251 particle.vx *= 0.96; 252 particle.vy *= 0.96; 253 particle.life -= 0.02; 254 255 if (particle.life <= 0) { 256 particles.splice(index, 1); 257 continue; 258 } 259 260 context.fillStyle = 'hsla(' + particle.hue + ', 95%, 70%, ' + particle.life + ')'; 261 context.beginPath(); 262 context.arc(particle.x, particle.y, 1 + particle.life * 2.5, 0, Math.PI * 2); 263 context.fill(); 264 } 265 266 if (particles.length > 400) { 267 particles.splice(0, particles.length - 400); 268 } 269 270 context.globalCompositeOperation = 'source-over'; 271 frame = window.requestAnimationFrame(draw); 272 } 273 274 return { 275 start: function () { 276 if (frame === null) { 277 frame = window.requestAnimationFrame(draw); 278 } 279 }, 280 feed: feed, 281 /** 282 * One last full-strength burst, then the animation is left to settle rather than being 283 * cut off mid frame. 284 */ 285 finish: function () { 286 progress = 1; 287 beat = 1; 288 289 window.setTimeout(function () { 290 if (frame !== null) { 291 window.cancelAnimationFrame(frame); 292 frame = null; 293 } 294 }, 1200); 295 } 296 }; 297 })(); 298 299 var worker = new Worker(widget.dataset.worker); 300 var difficulty = parseInt(widget.dataset.difficulty, 10); 301 var expectedHashes = Math.pow(2, difficulty); 302 303 if (visualizer) { 304 visualizer.start(); 305 } 306 307 /** 308 * A token the endpoint echoes back as a cookie, so the page can tell when the delivery has 309 * actually started. Only a UX signal: holding it authorises nothing. 310 */ 311 function ackToken() { 312 var bytes = new Uint8Array(16); 313 var token = ''; 314 var index; 315 316 if (window.crypto && window.crypto.getRandomValues) { 317 window.crypto.getRandomValues(bytes); 318 } else { 319 for (index = 0; index < bytes.length; index++) { 320 bytes[index] = Math.floor(Math.random() * 256); 321 } 322 } 323 324 for (index = 0; index < bytes.length; index++) { 325 token += (bytes[index] + 0x100).toString(16).slice(1); 326 } 327 328 return token; 329 } 330 331 function ackCookiePresent(ackName, token) { 332 return document.cookie.split(';').some(function (entry) { 333 return entry.trim() === ackName + '=' + token; 334 }); 335 } 336 337 /** 338 * Hands the request back to its original endpoint and gets out of the way. 339 * 340 * The form targets a hidden iframe, so this document stays loaded: a zip is written in full 341 * before its headers are sent, and unloading here would cancel the request. A download never 342 * fires `load` on the frame, so a `load` means the endpoint answered with a page instead -- 343 * an error, or a fresh challenge -- and the visitor should be looking at it rather than at a 344 * frame they cannot see. 345 * 346 * Returning waits for the acknowledgement cookie, which arrives with the download headers and at 347 * no earlier moment. Before those headers the request is still a navigation the frame owns, and 348 * leaving would cancel it; after them the browser owns the transfer and leaving is harmless. 349 */ 350 function replay() { 351 var returnUrl = form.dataset.powReturn; 352 var ackName = form.dataset.powAck; 353 var acknowledges = ackName !== ''; 354 var ackField = document.getElementById('pow_ack'); 355 var token = ackToken(); 356 // Ampache's own recommended configs send `Referrer-Policy: no-referrer`, so on most installs 357 // the server is handed nothing to build a return url from and falls back to the home page. 358 // The tab still knows where the visitor came from, so history is the route and the url the 359 // fallback, not the other way round. 360 var canGoBack = window.history.length > 1; 361 var timer = null; 362 var poll = null; 363 var leaving = false;
364 365 function stop() { 366 leaving = true; 367 window.clearTimeout(timer); 368 window.clearInterval(poll); 369 370 if (acknowledges) { 371 // Spent: a token left behind would let the next visit return before its own delivery. 372 document.cookie = ackName + '=; Path=/; Max-Age=0; SameSite=Lax'; 373 } 374 } 375 376 /** The endpoint answered with a page rather than a file, so show it instead of going back. */ 377 function showResponse(url) { 378 if (leaving || !url) { 379 return; 380 } 381 382 stop(); 383 window.location.replace(url); 384 } 385 386 function goBack() { 387 if (leaving) { 388 return; 389 } 390 391 stop(); 392 393 if (canGoBack) { 394 window.history.back(); 395 396 // A back() that lands unloads this page well inside the delay; one that finds 397 // nothing to return to is silent, and this is what catches it. 398 window.setTimeout(function () { 399 if (returnUrl) { 400 window.location.replace(returnUrl); 401 } 402 }, 700); 403 404 return; 405 } 406 407 if (returnUrl) { 408 window.location.replace(returnUrl); 409 } 410 } 411 412 if (sink) { 413 sink.onload = function () { 414 // Same origin, so the frame's own address is readable and there is nothing to 415 // rebuild; the form action is only there in case a browser withholds it. 416 var shown = form.action; 417 418 try { 419 shown = sink.contentWindow.location.href || shown; 420 } catch (error) { 421 shown = form.action; 422 } 423 424 showResponse(shown); 425 }; 426 } 427 428 if (ackField) { 429 ackField.value = token; 430 } 431 432 form.submit(); 433 434 say(text('Started', 'Your download has started.')); 435 436 if (returnLink) { 437 returnLink.hidden = false; 438 439 // The href is a working fallback on its own; this just routes the click through the 440 // same history-first path the timer uses. 441 var anchor = returnLink.querySelector('a'); 442 443 if (anchor) { 444 anchor.addEventListener('click', function (event) { 445 event.preventDefault(); 446 goBack(); 447 }); 448 } 449 } 450 451 // Where the endpoint acknowledges, the cookie is the signal and the timer only covers the 452 // case where it never arrives. Where it does not, the timer is all there is, so it is short 453 // enough not to strand the visitor: those endpoints stream, and their headers go out at once. 454 if (acknowledges) { 455 poll = window.setInterval(function () { 456 if (ackCookiePresent(ackName, token)) { 457 goBack(); 458 } 459 }, 250); 460 } 461 462 timer = window.setTimeout(goBack, acknowledges ? 30000 : 5000); 463 } 464 465 worker.onmessage = function (event) { 466 if (!event.data.done) { 467 var ratio = event.data.tried / expectedHashes; 468 469 if (visualizer) { 470 visualizer.feed(event.data.samples, event.data.best, ratio); 471 } 472 473 // Memoryless search: this is an average solve, not remaining work. `%%` is unescaped 474 // after `%d` so a literal percent cannot eat the placeholder. 475 say( 476 text('Progress', 'Checking your browser... %d%%') 477 .replace('%d', String(Math.min(99, Math.round(ratio * 100)))) 478 .replace(/%%/g, '%') 479 ); 480 481 return; 482 } 483 484 if (nonceField) { 485 nonceField.value = event.data.nonce; 486 } 487 488 worker.terminate(); 489 490 say(text('Passed', 'Check passed.')); 491 setSubmitEnabled(true); 492 493 if (!autoSubmit || !form) { 494 return; 495 } 496 497 if (!visualizer) { 498 replay(); 499 500 return; 501 } 502 503 // Long enough for the closing burst to read as an ending, short enough that nobody waiting 504 // on a download notices it. 505 visualizer.finish(); 506 window.setTimeout(replay, 600); 507 }; 508 509 worker.onerror = function () { 510 say(text('Error', 'The browser check failed to run. Please reload the page.')); 511 setSubmitEnabled(true); 512 }; 513 514 worker.postMessage({challenge: widget.dataset.challenge, difficulty: difficulty}); 515})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.