"""Wonder Ink's recent tabs, kept for two hours: temporary workspace restore. A teacher who closes Wonder Ink by accident, or whose computer restarts, gets their tabs back if they open it again soon. A room nobody has used for two hours starts fresh: the next teacher never sees the last one's tabs. It is not a sign-in and not a memory of tabs. Measured on the real site (docs/wonder-ink.md, *Temporary workspace restore*): WonderInk's sign-in is the teacher's email and password, Basic-auth encoded in a cookie, so keeping a sign-in would mean keeping a password. Nothing of the sign-in is kept here — no cookie, no header, no storage — and a restored tab that needs the teacher signed in gets WonderInk's own sign-in page. WHAT IS KEPT, AND ONLY THIS. format the version of this file last_activity wall-clock time the teacher last used Wonder Ink active which tab was in front tabs for each, in order: its kind ("web" or "worksheet"), its address or the worksheet's file, and its title Addresses are http or https and nothing else, and one that carries a credential — a user and password in it, or a query parameter named like a token, key, password or signature — is not kept at all. WHEN IT IS USED. Only when Wonder Ink starts, and only within IDLE_LIMIT_SECONDS of the last time the teacher used it. The time is the teacher's last use, not the last start: opening Wonder Ink and having its tabs restored is not using it, so closing and reopening cannot keep a workspace alive by itself. Anything that cannot be read, is from another version, is in the future by more than a minute (a clock gone backwards), or has failed to restore twice running is discarded, and Wonder Ink starts fresh. """ import json import os import time from pathlib import Path from urllib.parse import parse_qsl, urlsplit # Two hours after the teacher last used Wonder Ink. The one place this is # decided. IDLE_LIMIT_SECONDS = 2 * 60 * 60 # FOR TESTING ONLY. Checking expiry on a real machine should not take two # hours, so this variable, in the environment of a Wonder Ink started by # hand from the maintenance terminal, shortens it. Nothing in the classroom # sets it; it lasts as long as that one process, and it can only shorten. TEST_OVERRIDE = "CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS" TEST_SHORTEST = 10 # How far the clock may seem to have gone backwards before a workspace is # discarded for it. CLOCK_TOLERANCE_SECONDS = 60 # A workspace that has been started from this many times without Wonder # Ink then running long enough to say it worked is abandoned: one bad tab # must not make Wonder Ink unusable. RESTORE_ATTEMPTS = 2 FORMAT = 1 KINDS = ("web", "worksheet") # WonderInk's own site. Every page of it has the same address — it is one # page that draws each screen itself — so however many of its tabs a # teacher had, one is restored, and that one asks them to sign in. SITE_DOMAIN = "wonderink.org" # Query parameters that are, or may be, a credential. A tab whose address # has one is not kept. CREDENTIAL_WORDS = ( "password", "passwd", "pwd", "token", "auth", "secret", "key", "signature", "sig", "credential", "session", "jwt", "x-amz-", ) TITLE_LENGTH = 120 def idle_limit(environ=None): """Seconds after the teacher's last use that a workspace is kept.""" environ = os.environ if environ is None else environ value = environ.get(TEST_OVERRIDE, "").strip() if value: try: seconds = int(value) except ValueError: return IDLE_LIMIT_SECONDS return max(TEST_SHORTEST, min(seconds, IDLE_LIMIT_SECONDS)) return IDLE_LIMIT_SECONDS def is_test_limit(environ=None): return idle_limit(environ) != IDLE_LIMIT_SECONDS def is_site(host, site=SITE_DOMAIN): host = (host or "").lstrip(".").lower() return host == site or host.endswith("." + site) def keepable_url(url): """Is this an address that may be written down? http(s), no credential.""" try: parts = urlsplit(url) except ValueError: return False if parts.scheme not in ("http", "https") or not parts.hostname: return False if parts.username or parts.password: return False for name, _value in parse_qsl(parts.query, keep_blank_values=True): lowered = name.lower() if any(word in lowered for word in CREDENTIAL_WORDS): return False return True def is_site_start(url, site=SITE_DOMAIN): """Is this one of WonderInk's own pages, which all share one address?""" parts = urlsplit(url) return is_site(parts.hostname, site) and parts.path in ("", "/") def tab(kind, where, title=""): return {"kind": kind, "where": str(where), "title": str(title or "")[:TITLE_LENGTH]} def plan(tabs, active, downloads, site=SITE_DOMAIN): """Which of the saved TABS to open, in order, and which to put in front. Returns (tabs, active) with every tab one that may be opened: * a web tab only if its address may be kept (see keepable_url); * WonderInk's own tabs as one, where the first of them was; * a worksheet only if it is a file directly inside DOWNLOADS that still exists and still begins as a PDF — a state file cannot send Wonder Ink to any other file on the computer; * the tab in front is the one that was, or the nearest kept before it. """ downloads = Path(downloads).resolve() kept, index_of, site_seen = [], {}, None for number, item in enumerate(tabs): if not isinstance(item, dict) or item.get("kind") not in KINDS: continue where, title = str(item.get("where", "")), str(item.get("title", ""))[:TITLE_LENGTH] if item["kind"] == "web": if not keepable_url(where): continue if is_site_start(where, site): if site_seen is not None: index_of[number] = site_seen continue site_seen = len(kept) else: if not openable_worksheet(where, downloads): continue index_of[number] = len(kept) kept.append(tab(item["kind"], where, title)) if not kept: return [], 0 try: active = int(active) except (TypeError, ValueError): active = 0 for candidate in range(active, -1, -1): if candidate in index_of: return kept, index_of[candidate] return kept, 0 def openable_worksheet(path, downloads): try: resolved = Path(path).resolve() except (OSError, RuntimeError, ValueError): return False if resolved.parent != Path(downloads).resolve() or not resolved.is_file(): return False try: with open(resolved, "rb") as handle: return handle.read(5) == b"%PDF-" except OSError: return False def read(path, now=None, limit=None): """The workspace to restore, or why there is none. Returns (data, None) — with "tabs", "active", "last_activity" and "attempts" — or (None, reason). Anything that is not a workspace to restore is removed. """ now = time.time() if now is None else now limit = idle_limit() if limit is None else limit path = Path(path) try: data = json.loads(path.read_text()) except FileNotFoundError: return None, "none kept" except (OSError, ValueError): return _discard(path, "unreadable") try: if data["format"] != FORMAT: return _discard(path, "from another version") last = float(data["last_activity"]) tabs = list(data["tabs"]) active = int(data.get("active", 0)) attempts = int(data.get("attempts", 0)) except (KeyError, TypeError, ValueError): return _discard(path, "incomplete") if last > now + CLOCK_TOLERANCE_SECONDS: return _discard(path, "the clock is behind the last use") if now - last > limit: return _discard(path, "expired") if attempts >= RESTORE_ATTEMPTS: return _discard(path, f"it failed to restore {attempts} times") if not tabs: return _discard(path, "no tabs") return {"tabs": tabs, "active": active, "last_activity": last, "attempts": attempts}, None def write(path, tabs, active, last_activity, attempts=0): """Keep TABS, or remove the file if there is nothing to keep. Only what plan() would open again is written. Written whole to a temporary file, flushed to disk, and renamed over the old one, readable only by the classroom account: a crash or a power cut leaves the old file or the new one, never half of either. """ path = Path(path) keepable, front = [], 0 for number, item in enumerate(tabs): if item.get("kind") == "worksheet" or ( item.get("kind") == "web" and keepable_url(item.get("where", "")) ): if number <= active: front = len(keepable) keepable.append(tab(item["kind"], item["where"], item.get("title", ""))) if not keepable or last_activity is None: forget(path) return False path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(path.name + ".new") descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(descriptor, "w") as handle: json.dump({ "format": FORMAT, "last_activity": float(last_activity), "active": front, "attempts": int(attempts), "tabs": keepable, }, handle) handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) return True def forget(path): """Remove a kept workspace. Never fails: Wonder Ink must start regardless.""" path = Path(path) for target in (path, path.with_name(path.name + ".new")): try: target.unlink(missing_ok=True) except OSError: pass def _discard(path, reason): forget(path) return None, reason