Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apphosting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ env:
# Cloud Run region, so reads and writes stay in-region.
- variable: RESUME_BUCKET
value: dsgt-resumes
# Cloud Storage bucket holding bootcamp handouts — slides, notebooks,
# Cloud Storage bucket holding bootcamp workshop ZIPs — notebooks,
# datasets. Same runtime service account, same objectAdmin grant, already
# applied. Separate from the resume bucket on purpose: those are documents
# no member may read, these are files every enrolled member may.
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Members looking for a club-language overview should start at [Club project](./cl
| [Security](./operations/security.md) | Auth gates, rate limits, CSP, input scrubbing |
| [Testing](./operations/testing.md) | Vitest, Playwright, and what each suite protects |
| [Resume book](./resume-book.md) | Member uploads, the two staff views, limits, and why files skip tRPC |
| [Bootcamp materials](./bootcamp-materials.md) | Session handouts: who can download one, why the links are proxied, file limits |
| [Bootcamp materials](./bootcamp-materials.md) | Weekly workshop ZIPs: who can download one, publishing, session dates, storage |
| [Glossary](./glossary.md) | Club vs hackathon vocabulary |

## Packages
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Year-round DSGT operations. **Not** scoped to a hackathon row.
- Membership (`member`, `membership_history`) — one paid year per person, defined by start/end dates
- Club events and QR check-in (`event`, `event_check_in`)
- Bootcamp sessions are club events with `bootcamp_week` + `bootcamp_term`
- Bootcamp handouts (`bootcamp_material`) hang off those events; the bytes are in Cloud Storage and downloads are gated on the caller's term — see [Bootcamp materials](./bootcamp-materials.md)
- Bootcamp workshop material (`bootcamp_workshop`, one row per week) joins those events on `(term, week)`; the ZIPs are in Cloud Storage and downloads are gated on the workshop's own term — see [Bootcamp materials](./bootcamp-materials.md)
- Initiatives (`initiative`, `initiative_application`) led by `project_leader`
- Stripe payments and account linking

Expand Down
73 changes: 29 additions & 44 deletions docs/bootcamp-materials.md
Original file line number Diff line number Diff line change
@@ -1,68 +1,53 @@
# Bootcamp materials

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.
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.

Sessions themselves are ordinary events carrying `bootcamp_week` and `bootcamp_term`; see [Architecture](./architecture.md). Nothing here adds a second events stack.
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.

## Where things are

| Piece | Path |
| --- | --- |
| Table (metadata only) | `packages/db/src/schemas/bootcamp.ts` (`bootcamp_material`) |
| Read API | `packages/api/src/routers/bootcamp.ts` (`myProgress`, `attendance`) |
| Upload | `sites/mainweb/app/(portal)/api/bootcamp/materials/route.ts` |
| Serve / remove one file | `sites/mainweb/app/(portal)/api/bootcamp/materials/[id]/route.ts` |
| File rules (types, size, names) | `sites/mainweb/lib/bootcamp-materials.ts` |
| Access gate | `sites/mainweb/lib/bootcamp-access.ts` |
| Table (metadata only) | `packages/db/src/schemas/bootcamp.ts` (`bootcamp_workshop`) |
| API | `packages/api/src/routers/bootcamp.ts` (`workshops`, `adminWorkshops`, `createWorkshop`, `updateWorkshop`, `upsertSession`, `setPublished`) |
| Upload / serve / clear one ZIP | `sites/mainweb/app/(portal)/api/bootcamp/materials/[workshopId]/[kind]/route.ts` |
| Delete a workshop | `sites/mainweb/app/(portal)/api/bootcamp/materials/[workshopId]/route.ts` |
| ZIP rules (signature, cap, names) | `sites/mainweb/lib/bootcamp-file.ts` |
| Download rule | `sites/mainweb/lib/bootcamp-route-rules.ts` |
| Caller lookup | `sites/mainweb/lib/bootcamp-access.ts` |
| Bucket client | `sites/mainweb/lib/bootcamp-storage.ts` |
| Both UIs | `sites/mainweb/components/portal/BootcampMaterials.tsx` |
| Member page | `sites/mainweb/app/(portal)/club/bootcamp/page.tsx` |
| Staff page | `sites/mainweb/app/(portal)/admin/bootcamp/page.tsx` (`/admin/bootcamp`) |
| Member table / staff modal | `sites/mainweb/components/portal/BootcampMaterialsTable.tsx`, `BootcampWorkshopModal.tsx` |

## Who can read a file
## Who can download a file

The download route answers for one person and one file:
- Staff can download anything, drafts and past terms included.
- 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.
- Everyone else gets **404, not 403**, so a guessed workshop id can't confirm that a draft exists.

- Staff, always.
- 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.
- Everyone else gets **404, not 403**. Whether a file exists is itself worth nothing to someone who may not read it.
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.

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.
## Session dates

## Storage

Files live in `gs://dsgt-bootcamp` under `sessions/<eventId>/<materialId>.<ext>`. Postgres holds the name, size, content type and object key.

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.

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.
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.

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.
- **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.
- **Clearing a date** detaches the event (clears `bootcamp_week`/`bootcamp_term`) rather than deleting it, because deleting an event destroys its check-ins.
- 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.

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.

## Downloads are proxied, not signed

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.

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.
## Storage

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.
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.

## Limits
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.

| Rule | Value | Why |
| --- | --- | --- |
| Per file | 25MB | A deck with screenshots is 5-15MB; the ceiling is the download path, not storage |
| 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 |
| Uploads | 30 per officer per hour | Token bucket, same limiter as everything else |
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.

`.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.
Credentials are Application Default Credentials. Locally, run `gcloud auth application-default login`. With `BOOTCAMP_BUCKET` unset, the file routes return 503.

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.
## Known limitation

## Adding files
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.

`/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.
## Legacy table

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.
`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.
2 changes: 1 addition & 1 deletion docs/operations/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Without `DATABASE_URL`, `db` is null, sessions fall back to JWT, and tRPC proced
| Variable | Default / notes |
| --- | --- |
| `RESUME_BUCKET` | Cloud Storage bucket holding resume PDFs (App Hosting sets `dsgt-resumes`). Unset means uploads return 503 rather than failing obscurely. Credentials are ADC — the runtime service account needs `roles/storage.objectAdmin`. See [Resume book](../resume-book.md) |
| `BOOTCAMP_BUCKET` | Cloud Storage bucket holding bootcamp handouts (App Hosting sets `dsgt-bootcamp`). Unset means uploads and downloads return 503. Same ADC and same `roles/storage.objectAdmin` as above. Downloads are proxied and gated on the caller's bootcamp term, so the bucket keeps public access prevention on |
| `BOOTCAMP_BUCKET` | Cloud Storage bucket holding bootcamp workshop ZIPs (App Hosting sets `dsgt-bootcamp`). Unset means uploads and downloads return 503. Same ADC and same `roles/storage.objectAdmin` as above. Downloads are proxied and gated on the caller's bootcamp term, so the bucket keeps public access prevention on |

## Security / proxy

Expand Down
2 changes: 1 addition & 1 deletion docs/operations/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Tests use `src/test/create-mock-context.ts` and `.internal-tests/_db-tx-mock.ts`

## Mainweb

`sites/mainweb/lib/*.test.ts` — phone formatting, hackathon slugs, `safe-callback`, bootcamp handout file rules (`bootcamp-materials.test.ts`), the handout access gate (`bootcamp-access.test.ts`, where the null-term trap is held down) and the upload and download handlers (`bootcamp-material-routes.test.ts` — the handlers live under `app/`, which the root script does not glob).
`sites/mainweb/lib/*.test.ts` — phone formatting, hackathon slugs, `safe-callback`, bootcamp ZIP rules (`bootcamp-file.test.ts`), the download gate (`bootcamp-route-rules.test.ts`, where the null-term trap is held down) and the upload and download handlers (`bootcamp-routes.test.ts` — the handlers live under `app/`, which the root script does not glob).

## Hacklytics

Expand Down
2 changes: 1 addition & 1 deletion docs/packages/db.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Files in `src/schemas/`, re-exported from `schemas/index.ts`. `drizzle.config.ts
| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result` |
| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` |
| `events.ts` | `event`, `event_check_in` |
| `bootcamp.ts` | `bootcamp_material` (metadata; the files are in Cloud Storage) |
| `bootcamp.ts` | `bootcamp_workshop` (metadata; the ZIPs are in Cloud Storage). `bootcamp_material` is legacy and unused |
| `stripe.ts` | `stripe_payment`, `user_account_link` |
| `security.ts` | `audit_logs` (+ `security_severity` enum) |
| `settings.ts` | `system_settings` (single row, `id = 'default'`) |
Expand Down
6 changes: 3 additions & 3 deletions docs/sites/mainweb.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `p
| `/admin/attendees` | Attendee tools |
| `/admin/judging` | Judging admin (sync submissions + assign judges live here) |
| `/admin/initiatives` | Initiative / proposal review |
| `/admin/bootcamp` | Bootcamp attendance grid, and the handouts attached to each session |
| `/admin/bootcamp` | Bootcamp attendance grid and the weekly workshop table |
| `/admin/staff` | Admin users |
| `/admin/analytics` | Overview |
| `/admin/audit` | Audit log |
Expand All @@ -65,8 +65,8 @@ Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `p
| `/api/auth/[...nextauth]` | NextAuth handlers |
| `/api/auth/verify-email` | Email-code verification |
| `/api/webhooks/stripe` | Stripe webhooks |
| `/api/bootcamp/materials` | Attach a file to a bootcamp session (`POST`, staff) |
| `/api/bootcamp/materials/[id]` | Download one handout (`GET`, gated on the caller's bootcamp term) or remove it (`DELETE`, staff) |
| `/api/bootcamp/materials/[workshopId]` | Delete a workshop and its ZIPs (`DELETE`, staff) |
| `/api/bootcamp/materials/[workshopId]/[kind]` | Upload (`POST`, staff), download (`GET`, gated on the workshop's term and publish state) or clear (`DELETE`, staff) the `materials` or `solution` ZIP |

## Client data

Expand Down
24 changes: 24 additions & 0 deletions packages/api/src/.internal-tests/announcements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,30 @@ vi.mock("@query/db", () => {
admins: { userId: "user_id", isActive: "is_active", role: "role" },
users: { id: "id", name: "name", email: "email" },
hackathons: { id: "id", status: "status", isPublic: "is_public" },
bootcampWorkshops: {
id: "id",
term: "term",
week: "week",
title: "title",
materialsKey: "materials_key",
materialsFileName: "materials_file_name",
materialsSizeBytes: "materials_size_bytes",
solutionKey: "solution_key",
solutionFileName: "solution_file_name",
solutionSizeBytes: "solution_size_bytes",
recordingUrl: "recording_url",
isPublished: "is_published",
createdAt: "created_at",
updatedAt: "updated_at",
},
events: {
id: "id",
title: "title",
qrCode: "qr_code",
checkInEnabled: "check_in_enabled",
eventDate: "event_date",
currentCheckIns: "current_check_ins",
},
members: { userId: "user_id" },
projectLeaders: { userId: "user_id", isActive: "is_active" },
judges: { userId: "user_id", isActive: "is_active" },
Expand Down
Loading
Loading