PageSourceSearch

https://play.dogmazic.net/lib/javascript/pow.js

js dogmazic.net collected 2026-10-02 03:02:18 UTC 18,973 bytes, 515 lines download raw bytes

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.