tools/lab/wonderink-workspace-check launches the installed command again and again against a stand-in for WonderInk's start page whose sign-in is Basic auth, as WonderInk's is: restored after 5 and 90 minutes; fresh after two hours and a minute, a day and a week; reopened untouched until it lapses; a click after a restore counts and the restore does not; a busy page and an untouched print preview do not; Print does; closed, reordered, worksheet and missing-worksheet tabs; killed outright; a restore that crashes twice; corrupt, future-format and half-written files; the file holding no credential; restored tabs printing. 59 checks. The print check follows every kind of link on a signed-in page with one click — new tab in front for links and window.open, nothing for buttons and anchors, same-tab for no target, Back, closing — and prints a PDF and a picture reached by a link, and a page after several tabs. 0.2.6.3's scenarios are unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
196 lines
9.1 KiB
Python
196 lines
9.1 KiB
Python
"""A signed-in lesson with pictures in it, for right-click printing.
|
|
|
|
wonderink-lesson-site.py PICTURES PORT OTHER_PORT LOG
|
|
|
|
Run inside the lab by tools/lab/wonderink-print-check. Stands in for
|
|
Wonder Ink's own site, which the lab has no account for, with every kind
|
|
of picture a lesson might put under a teacher's pointer:
|
|
|
|
/login sets a session cookie (HttpOnly, SameSite=Lax), as a
|
|
sign-in does, and goes on to /lesson
|
|
/lesson the page; its pictures are all behind that cookie
|
|
/private/NAME a picture, 403 without the cookie
|
|
/once/N/NAME a picture that can be fetched exactly once — so a
|
|
print that fetches it again gets a 404, not a picture
|
|
OTHER_PORT the same pictures from another origin, with no CORS
|
|
|
|
and a stand-in for WonderInk's own start page and sign-in, built the way
|
|
the real site's is (read from its code, and measured with a real
|
|
account): the sign-in is cookies its JavaScript writes, and rrf-auth is
|
|
"Basic " and the base64 of the email and password — so a check can prove
|
|
that nothing Wonder Ink writes down holds one. Sign out calls
|
|
/rrframework/dataStore/signout and then sets them to "".
|
|
|
|
/app "Signed in as …" if rrf-auth is set, else a sign-in form
|
|
/app?busy=1 the same, plus what a site does by itself: a request
|
|
every second and an animation, and no input at all
|
|
/links every kind of link a lesson has, for following them
|
|
|
|
Every request is logged, one JSON line each, so a check can say what was
|
|
fetched while it printed. What was measured with this, and why Print
|
|
image fetches nothing, is in classroom-wonderink's _take_picture.
|
|
"""
|
|
|
|
import http.server
|
|
import json
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
PICTURES = Path(sys.argv[1])
|
|
PORT, OTHER = int(sys.argv[2]), int(sys.argv[3])
|
|
LOG = Path(sys.argv[4])
|
|
TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp", ".pdf": "application/pdf"}
|
|
fetched_once = set()
|
|
lock = threading.Lock()
|
|
|
|
LESSON = """<html><head><title>Picture lesson</title><style>
|
|
body { margin: 8px; font: 15px sans-serif; }
|
|
img, canvas { width: 180px; height: 120px; object-fit: contain; border: 1px solid #ccc; margin: 4px; }
|
|
</style></head><body>
|
|
<h1 style="color:#c00">Noah's Ark</h1>
|
|
<p id="words">Noah built an ark, and the animals came two by two. Colour the pictures below.</p>
|
|
<img id="private-jpg" src="/private/ark-wide.jpg">
|
|
<img id="private-png" src="/private/ark-tall.png">
|
|
<img id="clear" src="/private/ark-clear.png">
|
|
<img id="webp" src="/private/ark-wide.webp">
|
|
<img id="once" src="/once/NONCE/ark-wide.png">
|
|
<img id="cross" src="http://localhost:OTHER/img/ark-tall.png">
|
|
<img id="blob">
|
|
<canvas id="canvas" width="1600" height="1000"></canvas>
|
|
<img id="broken" src="/private/not-there.png">
|
|
<a href="/lesson#more"><img id="linked" src="/private/ark-tall.png"></a>
|
|
<p><a id="text-link" href="/links" target="_blank">More links for this lesson</a></p>
|
|
<script>
|
|
var waiting = 2;
|
|
function one() { if (--waiting === 0) window.__ready = true; }
|
|
fetch('/private/ark-tall.png', {credentials: 'include'})
|
|
.then(function (r) { return r.blob(); })
|
|
.then(function (b) { var i = document.getElementById('blob'); i.onload = one; i.src = URL.createObjectURL(b); });
|
|
var art = new Image();
|
|
art.onload = function () { document.getElementById('canvas').getContext('2d').drawImage(art, 0, 0); one(); };
|
|
art.src = '/private/ark-wide.png';
|
|
</script></body></html>"""
|
|
|
|
|
|
APP = """<html><head><title>App</title></head><body>
|
|
<div id="state"></div>
|
|
<input id="email" type="text"><input id="password" type="password">
|
|
<button id="signin">Sign In</button> <button id="signout">Sign Out</button>
|
|
<div id="spinner" style="width:20px;height:20px;background:#c00"></div>
|
|
<script>
|
|
function cookie(name) { var m = document.cookie.match('(^|; )' + name + '=([^;]*)'); return m ? m[2] : null; }
|
|
function show() { var t = cookie('rrf-auth');
|
|
document.getElementById('state').textContent = t ? 'Signed in as teacher' : 'Signed out'; document.title = t ? 'Signed in' : 'Sign-in'; }
|
|
document.getElementById('signin').onclick = function () {
|
|
// As the real site: the "token" is Basic auth — the email and password —
|
|
// in a cookie its JavaScript writes. Analytics cookies alongside, as there.
|
|
document.cookie = 'rrf-auth=' + encodeURIComponent('Basic ' + btoa('[email protected]:hunter2hunter2')) + '; path=/';
|
|
document.cookie = 'rrf-session-id=SID; path=/'; document.cookie = 'rrf_tsec_tok=T2; path=/';
|
|
document.cookie = '_ga=GA1.1.analytics; path=/'; document.cookie = '_fbp=fb.1.tracking; path=/'; show(); };
|
|
function server() { var x = new XMLHttpRequest(); x.open('GET', '/whoami', false); x.send(); return x.status; }
|
|
document.getElementById('signout').onclick = function () {
|
|
fetch('/rrframework/dataStore/signout').then(function () {
|
|
document.cookie = 'rrf-auth=; path=/'; document.cookie = 'rrf-session-id=; path=/'; document.cookie = 'rrf_tsec_tok=; path=/'; show(); }); };
|
|
show();
|
|
if (location.search.indexOf('busy=1') >= 0) {
|
|
setInterval(function () { fetch('/beacon?' + Date.now()); }, 1000);
|
|
var a = 0; (function spin() { a += 3; document.getElementById('spinner').style.transform = 'rotate(' + a + 'deg)'; requestAnimationFrame(spin); })();
|
|
}
|
|
</script></body></html>"""
|
|
|
|
LINKS = """<html><head><title>Links</title></head><body style="font: 16px sans-serif">
|
|
<h1 id="top">Links in a lesson</h1>
|
|
<p><a id="blank" target="_blank" rel="noopener noreferrer" href="/private/ark-wide.png">Saul Welcome Question Slide</a></p>
|
|
<p><a id="same" href="/lesson">The lesson, no target</a></p>
|
|
<p><a id="external" target="_blank" href="http://localhost:OTHER/img/ark-tall.png">A picture on another site</a></p>
|
|
<p><a id="pdf" target="_blank" href="/private/ark.pdf">The worksheet (PDF)</a></p>
|
|
<p><a id="linked-picture" target="_blank" href="/private/ark-tall.png"><img src="/private/ark-tall.png" width="120"></a></p>
|
|
<p><button id="button" onclick="this.textContent = 'Pressed'">A lesson button</button></p>
|
|
<p><a id="anchor" href="#bottom">Go to the bottom</a></p>
|
|
<p><button id="popup" onclick="window.open('/private/ark-clear.png', '_blank')">Open the picture</button></p>
|
|
<div style="height: 1500px"></div><p id="bottom">The bottom</p>
|
|
</body></html>"""
|
|
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, *_):
|
|
pass
|
|
|
|
def answer(self, code, body=b"", kind="text/plain", headers=()):
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", kind)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
for name, value in headers:
|
|
self.send_header(name, value)
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def picture(self, name):
|
|
path = PICTURES / Path(name).name
|
|
if not path.is_file() or path.suffix not in TYPES:
|
|
return self.answer(404, b"no such picture")
|
|
return self.answer(200, path.read_bytes(), TYPES[path.suffix],
|
|
[("Cache-Control", "private, max-age=600")])
|
|
|
|
def do_GET(self):
|
|
path = urlparse(self.path).path
|
|
signed_in = "session=ok" in self.headers.get("Cookie", "")
|
|
|
|
with lock, open(LOG, "a") as log:
|
|
log.write(json.dumps({"t": time.time(), "port": self.server.server_port,
|
|
"path": path, "signed_in": signed_in}) + "\n")
|
|
|
|
if self.server.server_port == OTHER:
|
|
if path.startswith("/img/"):
|
|
return self.picture(path[5:])
|
|
return self.answer(404)
|
|
|
|
if path == "/app":
|
|
return self.answer(200, APP.encode(), "text/html", [("Cache-Control", "no-store")])
|
|
|
|
if path == "/whoami":
|
|
token = re.search(r"(?:^|; )rrf-auth=([^;]+)", self.headers.get("Cookie", ""))
|
|
return self.answer(200 if token else 403, b"{}", "application/json")
|
|
|
|
if path in ("/rrframework/dataStore/signout", "/beacon"):
|
|
return self.answer(200, b"{}", "application/json")
|
|
|
|
if path == "/login":
|
|
return self.answer(302, headers=[
|
|
("Set-Cookie", "session=ok; Path=/; HttpOnly; SameSite=Lax"),
|
|
("Location", "/lesson")])
|
|
|
|
if not signed_in:
|
|
return self.answer(403, b"sign in first")
|
|
|
|
if path == "/links":
|
|
return self.answer(200, LINKS.replace("OTHER", str(OTHER)).encode(), "text/html")
|
|
|
|
if path == "/lesson":
|
|
page = LESSON.replace("NONCE", uuid.uuid4().hex).replace("OTHER", str(OTHER))
|
|
return self.answer(200, page.encode(), "text/html")
|
|
|
|
if path.startswith("/private/"):
|
|
return self.picture(path[9:])
|
|
|
|
if path.startswith("/once/"):
|
|
with lock:
|
|
if path in fetched_once:
|
|
return self.answer(404, b"this address has been used")
|
|
fetched_once.add(path)
|
|
return self.picture(path.rsplit("/", 1)[1])
|
|
|
|
return self.answer(404)
|
|
|
|
|
|
for port in (OTHER, PORT):
|
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
|
|
threading.Event().wait()
|