""" Streamlit front end for the STARS report redactor (student-facing). Privacy design: the uploaded PDF is redacted entirely IN MEMORY via `engine.redact_bytes` and is never written to the server's disk. Only the redacted copy is offered back to the student for download; nothing is stored unless the student explicitly chooses to submit it to BBH. Deployment: Hugging Face Space (Streamlit SDK). Set a Space secret named APP_PASSPHRASE to the access phrase before going live. Page layout (top to bottom): 1) What is BBH, and why are we doing this 2) Access code 3) Screenshots - what we keep / don't keep 4) Link to a full sample redacted STARS report 5) Try it yourself - upload & see 6) Consent form check (only required to SHARE your redacted copy) 7) Your code (shown after you submit) 8) Removal link """ import os import secrets import streamlit as st from redactors import engine PASSPHRASE = os.environ.get("APP_PASSPHRASE", "") # --- editable project constants (temporary; update here if they change) ---- CONTACT_EMAIL = "buaibuilderhub@gmail.com" # general questions DELETE_FORM_URL = ( # "delete my report" request "https://docs.google.com/forms/d/e/" "1FAIpQLSc2ewCuCLHSuci9eZPrpmdSJ2XsUDk9yXZ5ynFy5CXgkY9RWg/viewform" ) # Link to a full sample redacted report. Set this to the file's URL to show the # "View here" button; leave it empty and the whole section is hidden. # # It used to point at exampleRedactedStars.pdf in this repo, but that file was # never committed, so the button 404'd. Blanked rather than left broken; # upload the sample and put its URL back to restore the section. SAMPLE_REPORT_URL = "" # Explainer art, shown in this list order as the student scrolls. Paths are # relative to this file (not the working directory) so they resolve the same # way locally and in the Space. # # This previously listed explanationSS1/2/3.png, which were never committed to # the repo, so every student saw three error boxes where the explanation should # have been. The same ground is covered by redaction_explainer.png, which was # already here and unused. Anything listed but missing is now skipped silently: # a missing asset is our problem and shouldn't be shown to a student. KEEP_DONT_KEEP_IMAGES = [ ("redaction_explainer.png", "What gets removed from your report, and what stays in it"), ] APP_DIR = os.path.dirname(os.path.abspath(__file__)) # --- upload guardrails ------------------------------------------------------ # A real STARS report saved from experience.usc.edu is a text PDF of a few # pages, typically well under 1 MB. Anything far outside that is either the # wrong file or someone probing the app, so it is refused early and cheaply # rather than being handed to the redaction engine. The values are deliberately # generous, several times a normal report, so a legitimate student is never # caught by them. MAX_UPLOAD_MB = 10 # ~20x the size of a normal STARS report MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024 MAX_PAGES = 15 # a long real report runs about 5 pages MAX_PROCESSING_SECONDS = 30 # a normal report redacts in ~1-2 seconds # PDFs are the only thing this tool can read, and the only thing it accepts. # The uploader's type=["pdf"] filter goes on the FILENAME, so a file of any kind # renamed to .pdf gets past it; the real check is the "%PDF-" header in the # bytes. The spec puts that header at byte 0, but readers tolerate some junk in # front of it, so scan a short window rather than the first 5 bytes. ALLOWED_UPLOAD_EXTENSIONS = (".pdf",) PDF_MAGIC = b"%PDF-" PDF_HEADER_SCAN_BYTES = 1024 # --- report ID -------------------------------------------------------------- # The ID is a bearer token in the coat-check sense: it is the ONLY thing tying a # student to their stored report, and the only thing needed to ask us to delete # one. 16 bytes = 32 hex characters = 128 bits, which puts both guessing a valid # ID and two students colliding out of reach. # # Changing this changes the shape of the stored filename (.pdf). # admin/purge_submission.py validates incoming IDs against a matching 32-hex # pattern (it can't import this constant, because importing a Streamlit script runs # it), so if you change the length, update CURRENT_ID_RE there in the same # commit or deletion requests will start being rejected as malformed. REPORT_ID_BYTES = 16 # --- submission storage (a private HF Dataset in the org) ------------------- # In-app submission switches on automatically once these Space secrets are set: # HF_TOKEN a WRITE token with access to the dataset repo # SUBMISSIONS_REPO (optional) defaults to buai-builder-hub/stars-submissions HF_TOKEN = os.environ.get("HF_TOKEN", "") SUBMISSIONS_REPO = os.environ.get("SUBMISSIONS_REPO", "buai-builder-hub/redacted-stars-submissions") def _submissions_enabled(): return bool(HF_TOKEN) def _upload_submission(data, uid): """Save the REDACTED bytes to the private dataset repo as .pdf.""" from huggingface_hub import HfApi HfApi(token=HF_TOKEN).upload_file( path_or_fileobj=data, path_in_repo=f"{uid}.pdf", repo_id=SUBMISSIONS_REPO, repo_type="dataset", commit_message=f"submission {uid}", ) def _upload_rejection(upload): """Why this upload should be refused, as a message for the student. Returns None when the file is acceptable. Checks run cheapest-first: the filename, then the size, then the bytes themselves, so an enormous file is turned away on its size before anything reads it. """ name = (upload.name or "").lower() if not name.endswith(ALLOWED_UPLOAD_EXTENSIONS): return ("We can only take **PDF** files. Open your STARS report on " "experience.usc.edu, hit the red **Print** button, and choose " "**Save as PDF**, then upload that file.") if upload.size > MAX_UPLOAD_BYTES: return (f"That file is **{upload.size / 1024 / 1024:.1f} MB**, which is " f"over our **{MAX_UPLOAD_MB} MB** limit. A STARS report saved " "from experience.usc.edu is normally well under 1 MB, so this " "is probably a different file, so double-check you picked the " f"right one. If it really is your STARS report, email us at " f"{CONTACT_EMAIL} and we'll help.") # Named .pdf, but is it one? This is the check that a renamed file fails. if PDF_MAGIC not in upload.getvalue()[:PDF_HEADER_SCAN_BYTES]: return ("This file is named `.pdf`, but it doesn't look like a PDF " "inside, so we didn't try to read it. If you renamed a " "screenshot or a document, that won't work. Use **Print → Save " "as PDF** on experience.usc.edu and upload the file it gives " "you.") return None def _clear_cached_result(): """Forget any cached redaction. Called when an upload is refused. Without this, a student who first uploaded a good report and then picked a too-large file would still have the previous file's bytes sitting in session state, and the Submit section further down would happily offer to share them. """ for key in ("uid_key", "uid", "res", "out", "submitted", "preview"): st.session_state.pop(key, None) # --- in-page preview -------------------------------------------------------- # Streamlit has no PDF viewer, and browsers refuse to render a PDF served from a # data: URI inside an