|
1 | 1 | # Bootcamp materials |
2 | 2 |
|
3 | | -Staff attach files to a bootcamp session — slides, notebooks, datasets. Everyone enrolled in that bootcamp downloads them from `/club/bootcamp`; nobody else can, including members who did not buy it. |
| 3 | +Each week of a bootcamp has one workshop row: a title, a materials ZIP, a solution ZIP and a recording link. Staff manage the rows at `/admin/bootcamp` and publish each one when it's ready. Members download from `/club/bootcamp`. Nobody else can download, including members who didn't buy the bootcamp. |
4 | 4 |
|
5 | | -Sessions themselves are ordinary events carrying `bootcamp_week` and `bootcamp_term`; see [Architecture](./architecture.md). Nothing here adds a second events stack. |
| 5 | +Sessions themselves are ordinary events carrying `bootcamp_week` and `bootcamp_term`; see [Architecture](./architecture.md). A workshop row finds its session by `(term, week)`. There is no foreign key, so deleting a session never deletes the notebooks. |
6 | 6 |
|
7 | 7 | ## Where things are |
8 | 8 |
|
9 | 9 | | Piece | Path | |
10 | 10 | | --- | --- | |
11 | | -| Table (metadata only) | `packages/db/src/schemas/bootcamp.ts` (`bootcamp_material`) | |
12 | | -| Read API | `packages/api/src/routers/bootcamp.ts` (`myProgress`, `attendance`) | |
13 | | -| Upload | `sites/mainweb/app/(portal)/api/bootcamp/materials/route.ts` | |
14 | | -| Serve / remove one file | `sites/mainweb/app/(portal)/api/bootcamp/materials/[id]/route.ts` | |
15 | | -| File rules (types, size, names) | `sites/mainweb/lib/bootcamp-materials.ts` | |
16 | | -| Access gate | `sites/mainweb/lib/bootcamp-access.ts` | |
| 11 | +| Table (metadata only) | `packages/db/src/schemas/bootcamp.ts` (`bootcamp_workshop`) | |
| 12 | +| API | `packages/api/src/routers/bootcamp.ts` (`workshops`, `adminWorkshops`, `createWorkshop`, `updateWorkshop`, `upsertSession`, `setPublished`) | |
| 13 | +| Upload / serve / clear one ZIP | `sites/mainweb/app/(portal)/api/bootcamp/materials/[workshopId]/[kind]/route.ts` | |
| 14 | +| Delete a workshop | `sites/mainweb/app/(portal)/api/bootcamp/materials/[workshopId]/route.ts` | |
| 15 | +| ZIP rules (signature, cap, names) | `sites/mainweb/lib/bootcamp-file.ts` | |
| 16 | +| Download rule | `sites/mainweb/lib/bootcamp-route-rules.ts` | |
| 17 | +| Caller lookup | `sites/mainweb/lib/bootcamp-access.ts` | |
17 | 18 | | Bucket client | `sites/mainweb/lib/bootcamp-storage.ts` | |
18 | | -| Both UIs | `sites/mainweb/components/portal/BootcampMaterials.tsx` | |
19 | | -| Member page | `sites/mainweb/app/(portal)/club/bootcamp/page.tsx` | |
20 | | -| Staff page | `sites/mainweb/app/(portal)/admin/bootcamp/page.tsx` (`/admin/bootcamp`) | |
| 19 | +| Member table / staff modal | `sites/mainweb/components/portal/BootcampMaterialsTable.tsx`, `BootcampWorkshopModal.tsx` | |
21 | 20 |
|
22 | | -## Who can read a file |
| 21 | +## Who can download a file |
23 | 22 |
|
24 | | -The download route answers for one person and one file: |
| 23 | +- Staff can download anything, drafts and past terms included. |
| 24 | +- A member can download a **published** file when their `member.bootcamp_term` equals the **workshop's term**, not the current term. Someone who bought the fall bootcamp keeps the fall notebooks in January. They don't get the spring ones. |
| 25 | +- Everyone else gets **404, not 403**, so a guessed workshop id can't confirm that a draft exists. |
25 | 26 |
|
26 | | -- Staff, always. |
27 | | -- Anyone whose `member.bootcamp_term` equals the **term of the session the file hangs off** — not the current term. Somebody who bought the fall bootcamp keeps the fall notebooks in January. They do not get the spring ones, because their enrolment never said spring. |
28 | | -- Everyone else gets **404, not 403**. Whether a file exists is itself worth nothing to someone who may not read it. |
| 27 | +Publishing is its own procedure (`setPublished`), separate from saving. That keeps a row hidden until its uploads have landed, and a failed upload can't leave it visible. |
29 | 28 |
|
30 | | -A file is attached to the session event rather than to a week number, which is what makes that check one lookup: the term comes with the row. |
| 29 | +## Session dates |
31 | 30 |
|
32 | | -## Storage |
33 | | - |
34 | | -Files live in `gs://dsgt-bootcamp` under `sessions/<eventId>/<materialId>.<ext>`. Postgres holds the name, size, content type and object key. |
35 | | - |
36 | | -A second bucket rather than a prefix inside `dsgt-resumes`: resumes are documents no member may ever read and handouts are files every enrolled member may, and that difference is worth a bucket boundary rather than a path check. |
37 | | - |
38 | | -The object is named after the row id, never the uploaded filename. Two weeks can both hand out `slides.pdf`, and a name that arrived from a form has no business being a path. The extension rides along so a `gcloud storage cp` out of the bucket still produces a usable file. |
| 31 | +The modal's date and room fields call `upsertSession`, but only when the officer changed one of them. So saving a TBA workshop never touches an event. Term and week come from the workshop row, never from the clock, so editing a past cohort can't reach the current one. |
39 | 32 |
|
40 | | -Write order matches the resume path and for the same reason. Upload writes the object **before** the row — a row pointing at nothing is a download that 404s on a file the cohort was told to expect, while an object with no row costs a few cents. Delete reverses it. |
| 33 | +- **Setting a date** creates the week's event, or reschedules the existing one. The QR code is minted on insert only, so printed signs keep working. The event's title is left alone on reschedule. |
| 34 | +- **Clearing a date** detaches the event (clears `bootcamp_week`/`bootcamp_term`) rather than deleting it, because deleting an event destroys its check-ins. |
| 35 | +- A workshop's **week is fixed** once it's created, because moving it would leave its session behind. To renumber a workshop, delete it and create it again. |
41 | 36 |
|
42 | | -Credentials are Application Default Credentials; the App Hosting runtime service account has `roles/storage.objectAdmin` on the bucket (see `apphosting.yaml`). Locally, `gcloud auth application-default login`. With `BOOTCAMP_BUCKET` unset, uploads and downloads return 503 with a message rather than failing obscurely. |
43 | | - |
44 | | -## Downloads are proxied, not signed |
45 | | - |
46 | | -The bucket keeps uniform bucket-level access and public access prevention **enforced**. Every byte is streamed through the Next server, so a copied link is worth nothing to someone who is not signed in, and a member who leaves the bootcamp stops being able to use one they saved. |
47 | | - |
48 | | -A signed URL would be cheaper — the bytes would never touch the server — but it is a bearer token for the file that keeps working until it expires, and it would need `roles/iam.serviceAccountTokenCreator` on the runtime service account on top of that. If the traffic ever justifies it, that is the trade being made, not a free upgrade. |
| 37 | +## Storage |
49 | 38 |
|
50 | | -Streamed rather than buffered: 25MB times a class opening the slides at once is more than an instance has. The cost is that a read failing after the headers are out arrives as a truncated download rather than an error, which is why that failure is logged server-side. |
| 39 | +ZIPs live in `gs://dsgt-bootcamp` under `bootcamp/<workshopId>/<kind>.zip`. Postgres holds the object key, file name and size. Objects are keyed by the workshop id, which never changes. A key based on `(term, week)` would let one row's upload overwrite another row's file. |
51 | 40 |
|
52 | | -## Limits |
| 41 | +Uploads bypass tRPC and stream straight into the bucket. `uploadProcedure` caps at 2MB and superjson base64-encodes the body. The first four bytes are held until the ZIP signature checks out, and the stream stops at 20MB, which leaves headroom under Cloud Run's 32MiB request limit. |
53 | 42 |
|
54 | | -| Rule | Value | Why | |
55 | | -| --- | --- | --- | |
56 | | -| Per file | 25MB | A deck with screenshots is 5-15MB; the ceiling is the download path, not storage | |
57 | | -| Kinds | `pdf` `ipynb` `py` `csv` `json` `md` `txt` `zip` `pptx` `xlsx` `docx` `png` `jpg` `jpeg` | Allowlist. The entry **is** the `content-type` served back, so an unlisted kind has none and is refused | |
58 | | -| Uploads | 30 per officer per hour | Token bucket, same limiter as everything else | |
| 43 | +Downloads are proxied, never redirected to a signed URL, which would leave the origin and the auth check behind. The bucket keeps uniform bucket-level access and public access prevention **enforced**. The App Hosting runtime service account has `roles/storage.objectAdmin` on it. |
59 | 44 |
|
60 | | -`.html` and `.svg` are absent on purpose: both run script from our own origin. The download being `content-disposition: attachment` with `x-content-type-options: nosniff` is the second lock, not the only one. |
| 45 | +Credentials are Application Default Credentials. Locally, run `gcloud auth application-default login`. With `BOOTCAMP_BUCKET` unset, the file routes return 503. |
61 | 46 |
|
62 | | -Uploaded filenames are sanitised before they reach a header or a disk: path separators, control characters and leading dots come out, the extension stays, because the extension is what picks the content type. |
| 47 | +## Known limitation |
63 | 48 |
|
64 | | -## Adding files |
| 49 | +An upload and a removal of the same file can race, because the object write and the row update can't be one transaction. With a handful of officers, the impact is low. |
65 | 50 |
|
66 | | -`/admin/bootcamp` lists this term's sessions under **Handouts**, each with its files and an **Add file** control. Uploads go straight to the route handler rather than through tRPC — superjson base64s the whole body, which no slide deck survives. |
| 51 | +## Legacy table |
67 | 52 |
|
68 | | -There is no total-storage ceiling and no cleanup job. Deleting a session deletes its rows (`on delete cascade`) but not its objects; a term's worth is a few hundred megabytes, and a sweep that deletes from the bucket on a cascade is a sweep that can delete the wrong thing. |
| 53 | +`bootcamp_material` (#390's per-event handouts) is still declared in the schema, but nothing reads or writes it. Deploys run `drizzle-kit push`, and removing the declaration would make push stop at a DROP TABLE prompt. Drop the table in a separate change. |
0 commit comments