Skip to content
Open
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 firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; font-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' https://apis.google.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; style-src 'self' 'unsafe-inline'; connect-src 'self' https://firestore.googleapis.com https://*.googleapis.com; frame-src 'self' https://www.google.com https://accounts.google.com https://scaleropensourcelabs.com; form-action 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; upgrade-insecure-requests"
"value": "default-src 'self'; font-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' https://apis.google.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; style-src 'self' 'unsafe-inline'; connect-src 'self' https://firestore.googleapis.com https://*.googleapis.com https://asia-south1-osc-website-610b9.cloudfunctions.net; frame-src 'self' https://www.google.com https://accounts.google.com https://scaleropensourcelabs.com; form-action 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; upgrade-insecure-requests"
},
{
"key": "Strict-Transport-Security",
Expand Down
102 changes: 25 additions & 77 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -662,63 +662,10 @@ service cloud.firestore {
* LEVELS array with LEVEL_LABEL. Deployed as it was, it would have refused every real
* application while looking perfectly correct. `npm run rules` now diffs all four of
* these sets against the content. */
function isWellFormedApplication(d) {
return
d.keys().hasOnly([
'name', 'email', 'year_branch', 'hostel', 'github',
'level', 'path', 'programs', 'programs_other', 'submitted_at'
])
&& d.keys().hasAll([
'name', 'email', 'year_branch', 'hostel', 'level', 'path',
'programs', 'submitted_at'
])

// Required strings, bounded. The form's maxlength is a courtesy to the reader;
// these are the real limits, because a direct SDK call never sees the form.
&& d.name is string && d.name.size() > 0 && d.name.size() <= 120
&& d.email is string && d.email.size() > 3 && d.email.size() <= 200
// NOT restricted to the college domain, deliberately, and this is the one place
// that differs from every other rule here. Somebody applying from a personal
// address is an applicant to talk to, not a forgery to reject — the domain rule
// belongs on MEMBERSHIP rather than on the act of asking.
&& d.email.matches('^[^@\\s]+@[^@\\s]+[.][^@\\s]+$')
&& d.year_branch is string && d.year_branch.size() > 0 && d.year_branch.size() <= 120

&& (!('github' in d) || (d.github is string && d.github.size() > 0 && d.github.size() <= 100))

&& d.hostel in ['uniworld-1', 'uniworld-2']
&& d.level in ['beginner', 'intermediate']
&& d.path in ['build-day', 'first-contribution', 'fast-track', 'program-track']

// Programmes are REQUIRED and must be non-empty, which is the form's rule too —
// the browser refuses the submit without a tick. Enforced here as well because a
// direct SDK call never sees the form, and an application with an empty list would
// read as a UI bug to whoever opens it rather than as the forgery it is.
&& d.programs is list
&& d.programs.size() > 0
&& d.programs.size() <= 10
&& d.programs.hasOnly([
'gsoc', 'lfx', 'outreachy', 'sok', 'hacktoberfest', 'sob',
'gssoc', 'ssoc', 'esoc', 'other'
])
// 'other' and its free text are a pair, checked in BOTH directions: no bare
// 'other' with nothing to explain it, and no stray text without the tick that is
// supposed to have produced it.
&& (!d.programs.hasAny(['other'])
|| ('programs_other' in d
&& d.programs_other is string
&& d.programs_other.size() > 0))
&& (!('programs_other' in d)
|| (d.programs_other is string
&& d.programs_other.size() > 0
&& d.programs_other.size() <= 120
&& d.programs.hasAny(['other'])))

// The server's clock, not the submitter's. Everything else in this document was
// supplied by a stranger; this one field cannot be forged, so submission ORDER
// stays trustworthy even when nothing else does.
&& d.submitted_at == request.time;
}
// isWellFormedApplication WAS HERE. It validated the anonymous application form's
// shape and was the entire boundary on a collection strangers could write to. With
// the form gone and create denied, it guarded nothing — and an unused validator on a
// sealed collection is an invitation to reopen the collection by deleting one line.

// ---------------------------------------------------------------- members

Expand Down Expand Up @@ -1087,30 +1034,31 @@ service cloud.firestore {
// ---------------------------------------------------- legacy applications

match /applications/{id} {
// NOT LEGACY, AND THE COMMENT THAT SAID SO COST THE CLUB ITS FRONT DOOR. An upstream
// merge took this block as "nothing writes here any more; the profile replaced it"
// and denied create — but /join still renders components/ApplyForm.tsx, which writes
// exactly here. Every application submitted between that merge and this line was
// refused. The form rendered, the applicant filled it in, and the write failed.
// SEALED, AND THE COMMENT THIS REPLACES IS THE REASON TO READ CAREFULLY BEFORE
// CHANGING IT BACK. It said: "NOT LEGACY, AND THE COMMENT THAT SAID SO COST THE CLUB
// ITS FRONT DOOR" — an upstream merge had denied create while /join still rendered
// an application form, so every application submitted in between was refused. The
// form rendered, the applicant filled it in, the write failed, and nothing in the UI
// could have told anybody.
//
// Nothing in the UI could have told anybody: the rules were correct in git, correct
// in review, and wrong about which features existed.
// Denying create is correct NOW because the form is gone in this same change:
// components/ApplyForm.tsx and web/lib/applications.ts are deleted and /join renders
// the sign-in gate instead. Membership is an @sst.scaler.com address, which is the
// one thing a form could not check — signing in proves it in a tap and leaves a
// record nobody had to verify by hand.
//
// CREATE IS OPEN TO STRANGERS, on purpose — see isWellFormedApplication above for
// why applying must not require sign-in, and for why that function is therefore the
// whole boundary.
allow create: if isWellFormedApplication(request.resource.data);

// NOBODY READS THIS FROM A CLIENT, INCLUDING ADMINS. Organisers read applications in
// the Firebase console. That is a deliberate floor rather than a missing feature:
// these rows hold names, addresses and hostels belonging to people who are not
// members yet and never agreed to appear in anything, so the smallest surface that
// still lets the club act on them is the right one. An organisers' view would need
// its own admin-only rule AND a decision about retention, not a loosened read.
// THE TEST BEFORE YOU REOPEN THIS: does anything still WRITE here? If a form comes
// back, this line has to move with it, in the same commit. That coupling is the
// whole lesson of the incident above.
allow create: if false;

// NOBODY READS THIS FROM A CLIENT, INCLUDING ADMINS. The rows already here hold
// names, addresses and hostels belonging to people who were not members and never
// agreed to appear in anything. Organisers read them in the Firebase console. That
// is a deliberate floor rather than a missing feature.
allow read: if false;

// Immutable once sent, so the history is intact and an applicant cannot be edited
// into somebody else after an organiser has read them.
// Immutable, so the history stays intact.
allow update, delete: if false;
}

Expand Down
24 changes: 24 additions & 0 deletions web/app/(app)/admin/forms/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import AdminGate from "@/components/admin/Gate";
import SectionHead from "@/components/dashboard/SectionHead";
import FormBuilder from "@/components/FormBuilder";

// One organiser concern, one route. /admin was six of these on one page — an organiser
// opening it to post a notice scrolled past the membership table and a form builder.
// NOT a privilege gate; see components/admin/Gate.tsx.
export const metadata: Metadata = {
title: "Forms",
description: "Ask the club something and read the answers.",
robots: { index: false, follow: false },
};

export default function Page() {
return (
<AdminGate>
<SectionHead eyebrow="Organisers" title="Forms.">
Ask the club something and read the answers — sign-ups, and the odd &ldquo;which Saturday suits everyone&rdquo;.
</SectionHead>
<FormBuilder />
</AdminGate>
);
}
23 changes: 23 additions & 0 deletions web/app/(app)/admin/members/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import AdminGate from "@/components/admin/Gate";
import SectionHead from "@/components/dashboard/SectionHead";
import AdminDashboard from "@/components/AdminDashboard";

export const metadata: Metadata = {
title: "Members",
description: "The roster, the breakdowns, and the export.",
robots: { index: false, follow: false },
};

export default function MembersPage() {
return (
<AdminGate>
<SectionHead eyebrow="Organisers" title="Members.">
Everyone registered, and the breakdowns most often asked for. Batch, branch and year
are read from each member&apos;s college address rather than asked for, so they
cannot drift — and cannot be queried, which is why the breakdowns load on request.
</SectionHead>
<AdminDashboard />
</AdminGate>
);
}
12 changes: 12 additions & 0 deletions web/app/(app)/admin/mentorship/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import MentorshipAdmin from "@/components/admin/MentorshipAdmin";

export const metadata: Metadata = {
title: "Mentorship",
description: "Publish mentors, and see who has asked for whom.",
robots: { index: false, follow: false },
};

export default function AdminMentorshipPage() {
return <MentorshipAdmin />;
}
24 changes: 24 additions & 0 deletions web/app/(app)/admin/notices/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import AdminGate from "@/components/admin/Gate";
import SectionHead from "@/components/dashboard/SectionHead";
import Composer from "@/components/Composer";

// One organiser concern, one route. /admin was six of these on one page — an organiser
// opening it to post a notice scrolled past the membership table and a form builder.
// NOT a privilege gate; see components/admin/Gate.tsx.
export const metadata: Metadata = {
title: "Notices",
description: "The board every member reads on their dashboard. Post something and it lands there first.",
robots: { index: false, follow: false },
};

export default function Page() {
return (
<AdminGate>
<SectionHead eyebrow="Organisers" title="Notices.">
The board every member reads on their dashboard. Post something and it lands there first.
</SectionHead>
<Composer />
</AdminGate>
);
}
47 changes: 11 additions & 36 deletions web/app/(app)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,24 @@
import type { Metadata } from "next";
import AdminDashboard from "@/components/AdminDashboard";
import AudienceBackfill from "@/components/AudienceBackfill";
import Composer from "@/components/Composer";
import FormBuilder from "@/components/FormBuilder";
import Roster from "@/components/Roster";
import Sessions from "@/components/Sessions";
import AdminOverview from "@/components/admin/Overview";

// THE ORGANISERS' PAGE. Same shell as the member dashboard — it comes from
// (app)/layout.tsx, so this file is only the content.
// THE ORGANISERS' OVERVIEW. This route used to render everything: the membership table,
// the mentor list, the interest list, the notice composer, the session editor, the form
// builder and the roster — six components and about 3,800 lines, in one column.
//
// It is the numbers and a way in now. Each concern has its own route under /admin, and the
// sidebar switches to them while you are in here.
//
// NOT A PRIVILEGE GATE. The page ships to anybody who asks for it, because the site is a
// static export with no server to refuse them. What refuses them is the `list` rule on
// users/{uid} and the admin-only writes on every collection below, none of which any
// client can talk its way past. A non-admin who loads this URL gets a page whose every
// panel renders its own "not for you" state.
//
// THE ORDER IS BY HOW OFTEN AN ORGANISER DOES THE THING: membership is the question the
// page is opened with, notices and sessions are weekly, forms every few weeks, and the
// roster once a term — which is why it is last, where nobody reaches it by accident.
// users/{uid} and the admin-only writes on every collection these pages touch. See
// components/admin/Gate.tsx.

export const metadata: Metadata = {
title: "Organisers",
description: "Club membership, sessions, notices and forms.",
description: "Club membership, mentorship, sessions, notices and forms.",
robots: { index: false, follow: false },
};

export default function Admin() {
return (
<div className="space-y-5">
<div>
<h1 className="font-display text-display-lg font-bold tracking-tight">
Admin dashboard
</h1>
<p className="measure mt-2 text-body text-haze">
Who is in the club, what they have been told, and what you have asked them.
</p>
</div>

{/* Renders only while there is something to migrate — see the component. */}
<AudienceBackfill />
<AdminDashboard />
<Composer />
<Sessions />
<FormBuilder />
<Roster />
</div>
);
return <AdminOverview />;
}
24 changes: 24 additions & 0 deletions web/app/(app)/admin/sessions/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import AdminGate from "@/components/admin/Gate";
import SectionHead from "@/components/dashboard/SectionHead";
import Sessions from "@/components/Sessions";

// One organiser concern, one route. /admin was six of these on one page — an organiser
// opening it to post a notice scrolled past the membership table and a form builder.
// NOT a privilege gate; see components/admin/Gate.tsx.
export const metadata: Metadata = {
title: "Sessions",
description: "When the club meets, and what is on. A session is the most time-bound thing a member sees.",
robots: { index: false, follow: false },
};

export default function Page() {
return (
<AdminGate>
<SectionHead eyebrow="Organisers" title="Sessions.">
When the club meets, and what is on. A session is the most time-bound thing a member sees.
</SectionHead>
<Sessions />
</AdminGate>
);
}
24 changes: 24 additions & 0 deletions web/app/(app)/admin/team/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import AdminGate from "@/components/admin/Gate";
import SectionHead from "@/components/dashboard/SectionHead";
import Roster from "@/components/Roster";

// One organiser concern, one route. /admin was six of these on one page — an organiser
// opening it to post a notice scrolled past the membership table and a form builder.
// NOT a privilege gate; see components/admin/Gate.tsx.
export const metadata: Metadata = {
title: "Team",
description: "Who is an organiser, and what the public site says about them.",
robots: { index: false, follow: false },
};

export default function Page() {
return (
<AdminGate>
<SectionHead eyebrow="Organisers" title="Team.">
Who is an organiser, and what the public site says about them.
</SectionHead>
<Roster />
</AdminGate>
);
}
12 changes: 12 additions & 0 deletions web/app/(app)/dashboard/details/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import DetailsSection from "@/components/dashboard/DetailsSection";

export const metadata: Metadata = {
title: "Your details",
description: "The record the club holds about you.",
robots: { index: false, follow: false },
};

export default function DetailsPage() {
return <DetailsSection />;
}
14 changes: 14 additions & 0 deletions web/app/(app)/dashboard/mentorship/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import MentorshipSection from "@/components/dashboard/MentorshipSection";

// `noindex`, like every signed-in route: a page that means nothing without a session has
// no business in a search result. NOT a privilege gate — see RequireProfile.tsx.
export const metadata: Metadata = {
title: "Mentorship",
description: "The club's GSoC cohort, and the mentors you can ask for.",
robots: { index: false, follow: false },
};

export default function MentorshipPage() {
return <MentorshipSection />;
}
6 changes: 3 additions & 3 deletions web/app/(app)/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export const metadata: Metadata = {

export default function Onboarding() {
return (
<main id="main">
<section className="section page-top pb-8">
<>
<section className="section pb-8">
{/* CENTRED AND NARROW, which the dashboard is not. This is a single task with one
control at the end of it, and a form column stretched across a 1400px page is
the layout that makes a sign-up feel like paperwork — the name and GitHub
Expand Down Expand Up @@ -58,6 +58,6 @@ export default function Onboarding() {
</div>
</div>
</section>
</main>
</>
);
}
Loading
Loading