fix: harden classroom presentations

This commit is contained in:
ekklesia
2026-09-23 00:02:25 -05:00
parent ec1e0d1cd7
commit 6f2d169355
16 changed files with 709 additions and 215 deletions
+199 -68
View File
@@ -12,10 +12,9 @@ import button, no "where did you save it". This is the in between.
WHAT IT WILL NOT DO.
It never touches the teacher's download. The file Chrome wrote stays
where Chrome wrote it, with the name Chrome gave it, for as long as
the teacher leaves it there. Everything the library does is done to
its own copy.
It never leaves a duplicate in Downloads. Once a supported download is
complete, its one original is moved into the managed library. Generated
PDFs and page images live beside it and can always be rebuilt from it.
It never imports a Wonder Ink worksheet. Wonder Ink downloads into a
directory of its own inside Downloads and those are worksheets, not
@@ -31,11 +30,13 @@ WHAT IT WILL NOT DO.
"""
import argparse
import errno
import os
import shutil
import sys
import time
import uuid
from pathlib import Path
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
@@ -71,14 +72,159 @@ def new_directory(root):
return directory
def sync_directory(directory):
"""Best-effort persistence for a rename or unlink transaction."""
try:
descriptor = os.open(directory, os.O_RDONLY)
except OSError:
return
try:
os.fsync(descriptor)
except OSError:
pass
finally:
os.close(descriptor)
def move_download(source, destination):
"""Move one completed download without risking it on another filesystem.
rename is atomic on one filesystem. Across filesystems, copy to a hidden
temporary file, flush it, publish it atomically, and only then unlink the
source. If unlinking fails, the published copy is removed and the source
remains the one original for the next scan.
"""
source = os.fspath(source)
destination = os.fspath(destination)
temporary = destination + ".moving"
os.makedirs(os.path.dirname(destination), exist_ok=True)
if os.path.exists(destination) or os.path.exists(temporary):
raise FileExistsError(destination)
try:
os.replace(source, destination)
sync_directory(os.path.dirname(destination))
sync_directory(os.path.dirname(source))
return
except OSError as error:
if error.errno != errno.EXDEV:
raise
try:
with open(source, "rb") as incoming, open(temporary, "xb") as outgoing:
shutil.copyfileobj(incoming, outgoing, length=1024 * 1024)
outgoing.flush()
os.fsync(outgoing.fileno())
shutil.copystat(source, temporary, follow_symlinks=True)
os.replace(temporary, destination)
sync_directory(os.path.dirname(destination))
try:
os.unlink(source)
except OSError:
# Roll back to the still-complete source rather than leave two
# originals or make a failed move look successful.
try:
os.unlink(destination)
sync_directory(os.path.dirname(destination))
except OSError:
pass
raise
sync_directory(os.path.dirname(source))
except Exception:
try:
os.unlink(temporary)
except OSError:
pass
raise
def forget_presentation(presentation_id, root):
"""Remove transaction records for one interrupted presentation."""
data = library.ledger(root)
changed = False
for mark, value in list(data.items()):
if value == presentation_id:
data.pop(mark, None)
changed = True
if changed:
library.write_json(library.ledger_path(root), data)
def prepare(presentation):
"""Build the derived PDF, pages and cover from a managed original."""
data = presentation.data
managed = presentation.managed_copy
if managed is None or not managed.is_file():
raise RuntimeError("the managed original is missing")
# A restart may find conversion or rendering output from the interrupted
# attempt. The managed original is the recovery point; everything else is
# derived and is rebuilt cleanly.
if presentation.slides != managed:
try:
presentation.slides.unlink()
except OSError:
pass
shutil.rmtree(presentation.pages_directory, ignore_errors=True)
try:
presentation.thumbnail.unlink()
except OSError:
pass
if data["format"] == "pdf":
shutil.copy2(managed, presentation.slides)
else:
converted, error = library.convert_to_pdf(
managed, presentation.slides)
if not converted:
raise RuntimeError(error or "the file could not be converted")
pages, error = library.render_pages(
presentation.slides, presentation.pages_directory)
if not pages:
raise RuntimeError(error or "the slides could not be rendered")
data["pages"] = pages
data["title"] = library.unique_title(
library.readable_title(
data.get("original_name", ""),
library.first_text(presentation.slides)),
library.titles_in_use(presentation.directory.parent,
exclude=presentation.id),
)
library.render_thumbnail(presentation.page_image(1),
presentation.thumbnail)
data["state"] = "ready"
data["error"] = None
presentation.save()
say(f"imported {data['title']!r} ({pages} slides)")
return presentation
def import_one(source, root=None):
"""Take one finished download into the library.
Ordered so that an interruption is always recoverable. The metadata
is written first, saying "importing"; the sweep at startup throws
away anything still saying that and lets it be imported again. A
presentation only becomes visible to a teacher once every file it
needs is on disk.
Ordered so that an interruption is always recoverable. Metadata and the
transaction record are written before the move. Startup either retries
from Downloads or resumes from the moved managed original. A presentation
becomes ready only once every derived file it needs is on disk.
"""
root = root if root is not None else library.library_root()
mark = library.fingerprint(source)
@@ -87,7 +233,7 @@ def import_one(source, root=None):
return None
directory = new_directory(root)
managed = directory / source.name
managed = directory / "original" / source.name
data = {
"id": directory.name,
@@ -95,7 +241,7 @@ def import_one(source, root=None):
"title": library.readable_title(source.name),
"original_name": source.name,
"source_path": str(source),
"managed_copy": source.name,
"managed_copy": str(managed.relative_to(directory)),
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
"imported_at": library.stamp(),
"last_presented_at": None,
@@ -116,47 +262,22 @@ def import_one(source, root=None):
say(f"importing {source.name}")
try:
# THE TEACHER'S OWN DOWNLOAD IS COPIED, NEVER MOVED.
shutil.copy2(source, managed)
if data["format"] == "pdf":
# Already the thing everything else is converted into. A
# session exported from Google Slides as a PDF keeps exactly
# the appearance Google gave it, with no conversion at all.
shutil.copy2(managed, presentation.slides)
else:
converted, error = library.convert_to_pdf(
managed, presentation.slides)
if not converted:
raise RuntimeError(error or "the file could not be converted")
pages, error = library.render_pages(
presentation.slides, presentation.pages_directory)
if not pages:
raise RuntimeError(error or "the slides could not be rendered")
data["pages"] = pages
data["title"] = library.unique_title(
library.readable_title(
source.name, library.first_text(presentation.slides)),
library.titles_in_use(root, exclude=presentation.id),
)
library.render_thumbnail(presentation.page_image(1),
presentation.thumbnail)
data["state"] = "ready"
presentation.save()
say(f"imported {data['title']!r} ({pages} slides)")
return presentation
move_download(source, managed)
except Exception as error: # noqa: BLE001
# A card that says it could not be opened, rather than a card
# that opens onto nothing. The teacher's download is untouched
# either way, which is what the message says.
# A failed move leaves the source in Downloads. Remove the transaction
# and retry on the next scan; there is no half-owned file to show.
forget_presentation(presentation.id, root)
shutil.rmtree(directory, ignore_errors=True)
say(f"could not move {source.name}: {error}")
return None
try:
return prepare(presentation)
except Exception as error: # noqa: BLE001
# Conversion and rendering failures keep the managed original. The
# teacher can remove it, and a technician can recover or inspect it;
# no successfully downloaded file is lost because a derived asset
# could not be made.
data["state"] = "failed"
data["error"] = str(error)[:300]
presentation.save()
@@ -167,36 +288,47 @@ def import_one(source, root=None):
def sweep(root=None):
"""Throw away imports that were interrupted, so they can happen again.
"""Recover imports interrupted during a move, conversion or rendering.
A reboot in the middle of a conversion leaves a directory that says
"importing" and a ledger entry claiming the download is dealt with.
Both go, and the next scan imports it properly.
If Downloads still has the source, discard only the incomplete managed
copy and let the next scan retry. If the source has already been moved,
the managed original is the recovery point and its derived assets are
rebuilt in place. This distinction prevents a crash after the move from
deleting the only copy of a successful download.
"""
root = root if root is not None else library.library_root()
if not root.is_dir():
return 0
ledger = library.ledger(root)
stale = 0
for presentation in library.library(root):
if presentation.state != "importing":
continue
say(f"clearing an import that did not finish: {presentation.title!r}")
source_text = presentation.data.get("source_path")
source = Path(source_text) if source_text else None
managed = presentation.managed_copy
for mark, value in list(ledger.items()):
if value == presentation.id:
ledger.pop(mark, None)
if (managed is not None and managed.is_file()
and (source is None or not source.exists())):
say(f"resuming an interrupted import: {presentation.title!r}")
try:
prepare(presentation)
except Exception as error: # noqa: BLE001
presentation.data["state"] = "failed"
presentation.data["error"] = str(error)[:300]
presentation.save()
say(f"could not import {presentation.title!r}: {error}")
stale += 1
continue
say(f"retrying an interrupted move: {presentation.title!r}")
forget_presentation(presentation.id, root)
shutil.rmtree(presentation.directory, ignore_errors=True)
stale += 1
if stale:
library.write_json(library.ledger_path(root), ledger)
return stale
@@ -222,9 +354,8 @@ def scan(root=None, downloads=None):
def cleanup(root=None, moment=None):
"""Remove presentations whose time is up.
Only ever the library's own directory, and never the presentation on
the screen. The teacher's download is not part of this and is not
looked at.
Only ever the selected managed directory, including its one original and
derived assets, and never the presentation on the screen.
"""
root = root if root is not None else library.library_root()
lock = library.presenting_lock()
@@ -254,8 +385,8 @@ def cleanup(root=None, moment=None):
say(f"removing {presentation.title!r} — {why}")
shutil.rmtree(presentation.directory, ignore_errors=True)
removed += 1
if library.remove(presentation):
removed += 1
return removed