Skip to content

Commit eba2bdb

Browse files
committed
add docs and fix eslint issues
1 parent d351793 commit eba2bdb

9 files changed

Lines changed: 112 additions & 44 deletions

File tree

src/server/actions/signIn.ts

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,23 @@
11
"use server";
22

3-
import { headers } from "next/headers";
43
import { notFound } from "next/navigation";
54
import * as z from "zod";
6-
import { env } from "~/env";
75
import { authenticate } from "../auth";
6+
import { getCallbackPath } from "../utilts";
87

9-
const callbackPathSchema = z.string().transform((path, ctx) => {
10-
try {
11-
const url = new URL(String(path), env.BASE_URL);
12-
return url.toString().replace(url.origin, "");
13-
} catch {
14-
ctx.addIssue({
15-
code: "custom",
16-
message: "Provided string should be a path.",
17-
input: path,
18-
});
19-
return z.NEVER;
20-
}
21-
});
22-
23-
const realmSchema = z.literal(["google", "discord", "github"]);
8+
const realmSchema = z.literal(["uga", "discord", "github"]);
249

10+
/**
11+
* Begins the authentication flow for a user.
12+
* @param formData Allows two fields.
13+
* - `realm` is the authentication provider. Defaults to `"uga"`.
14+
* - `callbackPath` is the path to redirect the user to after the action completes. Defaults to the value of the `referer` header if present or `/` if not.
15+
*/
2516
export default async function signIn(formData: FormData) {
17+
const callbackPath = await getCallbackPath("/", formData);
2618
const realm = await realmSchema
2719
.parseAsync(formData.get("realm"))
28-
.catch(() => "google" as const);
29-
30-
const callbackPath = await callbackPathSchema
31-
.parseAsync(formData.get("callbackPath"))
32-
.catch(() =>
33-
headers().then((h) => callbackPathSchema.parseAsync(h.get("referer"))),
34-
)
35-
.catch(() => undefined);
20+
.catch(() => "uga" as const);
3621

3722
await authenticate(realm, callbackPath);
3823

src/server/auth/index.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,14 @@ export async function getSessionUser<
4242
const redirect_uri = new URL("/api/auth", env.BASE_URL).toString();
4343
const response_type = "code";
4444

45+
/**
46+
* Redirects the user to the appropriate OAuth consent URL.
47+
* @param realm The authentication provider
48+
* @param callbackPath Where to redirect the user after the authentication flow is complete (defaults to `/`)
49+
* @see https://medium.com/codenx/oauth-2-0-4cddd6c7471f
50+
*/
4551
export async function authenticate(
46-
realm: "google" | "discord" | "github",
52+
realm: "uga" | "discord" | "github",
4753
callbackPath?: string,
4854
) {
4955
const [insertedState] = await db
@@ -111,6 +117,20 @@ const searchParamsSchema = z
111117

112118
const grant_type = "authorization_code";
113119

120+
/**
121+
* Next.js Route handler for an OAuth callback request.
122+
* @param request The incoming request object, which expects two of three valid search parameters shown below.
123+
*
124+
* **Required always:**
125+
* - `state` &mdash; The token identifying the current state of the authentication flow.
126+
*
127+
* **Requires exactly one of:**
128+
* - `code` &mdash; During the standard OAuth flow, this value allows for retrieval of a user's `access_token`. The `access_token` will then be used to request profile information about the authenticated user, which will then be inserted into the database. If this is the user's first time signing in and they are using a realm other than `uga`, they will be redirected to authenticate with `uga` first. This second authentication request will use `/api/auth?state=...&linkProfile=...` as the callback path.
129+
* - `linkProfile` &mdash; After a standard OAuth flow, this value allows for a user's non-`uga` profile to be linked to their newly created account.
130+
*
131+
* @returns never, when awaited (a `redirect()`, `notFound()`, or `unauthorized()` error will always be thrown)
132+
* @see https://medium.com/codenx/oauth-2-0-4cddd6c7471f
133+
*/
114134
export async function handleOAuthRedirect(request: NextRequest) {
115135
const cookieStore = await cookies();
116136
const params = await searchParamsSchema
@@ -120,7 +140,9 @@ export async function handleOAuthRedirect(request: NextRequest) {
120140
const provider = providers[params.state.realm];
121141

122142
if ("linkProfile" in params) {
123-
if (provider.name === "google") {
143+
// [Case 2] A user tried to sign in for the first time using a non-`uga` account. They've completed creaeting the UGA account, but we want to automatically link their non-`uga` profile to the `uga` user.
144+
145+
if (provider.name === "uga") {
124146
notFound();
125147
}
126148

@@ -137,6 +159,7 @@ export async function handleOAuthRedirect(request: NextRequest) {
137159
redirect(params.state.callbackPath);
138160
}
139161

162+
// [Case 1] A user is following the standard OAuth flow. We request the `access_token` from the provider and get the associated profile data.
140163
const tokens = await fetch(provider.tokensRequest.url, {
141164
method: "POST",
142165
headers: {
@@ -154,14 +177,17 @@ export async function handleOAuthRedirect(request: NextRequest) {
154177
.then((res) => res.json())
155178
.then((obj) => tokenResultSchema.parseAsync(obj));
156179

180+
// We wait to parse the data so TypeScript can appropriately infer types.
157181
const profileData: unknown = await fetch(provider.profileRequest.url, {
158182
headers: {
159183
Accept: "application/json",
160184
Authorization: `Bearer ${tokens.accessToken}`,
161185
},
162186
}).then((res) => res.json());
163187

164-
if (provider.name === "google") {
188+
if (provider.name === "uga") {
189+
// [Case 1-1] The user is signing in with their `uga` account.
190+
165191
const profile =
166192
await provider.profileRequest.validator.parseAsync(profileData);
167193

@@ -172,6 +198,7 @@ export async function handleOAuthRedirect(request: NextRequest) {
172198
where: eq(users.email, profile.email),
173199
columns: { id: true },
174200
})) ??
201+
// Case [1-1-1] The user does not already exist: insert them and return their ID.
175202
(await tx.insert(provider.table).values(profile).$returningId())[0];
176203

177204
if (!user) {
@@ -204,6 +231,8 @@ export async function handleOAuthRedirect(request: NextRequest) {
204231
redirect(params.state.callbackPath);
205232
}
206233

234+
// [Case 1-2] The user is signing in with their non-`uga` account.
235+
207236
const profile =
208237
await provider.profileRequest.validator.parseAsync(profileData);
209238
const sessionToken = cookieStore.get("session")?.value;
@@ -219,6 +248,7 @@ export async function handleOAuthRedirect(request: NextRequest) {
219248

220249
const user =
221250
session?.user ??
251+
// Case [1-2-1] The user does not currently have a session: find them using the linked profile ID.
222252
(await tx.query.users.findFirst({
223253
where: eq(users[provider.userRelationColumnName], profile.id),
224254
}));
@@ -234,6 +264,7 @@ export async function handleOAuthRedirect(request: NextRequest) {
234264
});
235265

236266
if (!user) {
267+
// Case [1-2-2] The user is signing in for the first time using a non-`uga` account: we can't create a session for them (yet) because there is no `user` to associate this profile with.
237268
return null;
238269
}
239270

@@ -269,8 +300,9 @@ export async function handleOAuthRedirect(request: NextRequest) {
269300
});
270301

271302
if (token === null) {
303+
// Continued: [Case 1-2-2] Begin the authenticaiton flow using `uga`. See [Case 2].
272304
return await authenticate(
273-
"google",
305+
"uga",
274306
request.nextUrl.pathname +
275307
"?" +
276308
new URLSearchParams({

src/server/auth/providers.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,60 @@ import type { tokenResultSchema } from ".";
88
interface OAuthProvider<
99
Table extends Extract<(typeof schema)[keyof typeof schema], MySqlTable>,
1010
> {
11+
/**
12+
* Sent in consent and token requests as `client_id`.
13+
*/
1114
clientId: string;
15+
/**
16+
* Sent in token requests as `client_secret`.
17+
*/
1218
clientSecret: string;
19+
/**
20+
* Defines the base URL and additional search parameters (e.g., `scope`) for redirecting a user to begin the OAuth flow.
21+
*/
1322
consentRequest: {
1423
url: string;
1524
params: Record<string, string>;
1625
};
26+
/**
27+
* Defines the URL for requesting `access_token` (and, optionally, `refresh_token`) once a `code` has been provided.
28+
*/
1729
tokensRequest: {
1830
url: string;
1931
};
32+
/**
33+
* Defines the URL and response schema for requesting a user's profile information using `access_token`.
34+
*/
2035
profileRequest: {
2136
url: string;
2237
validator: z.ZodType<
2338
Omit<Table["$inferInsert"], keyof z.output<typeof tokenResultSchema>>
2439
>;
2540
};
41+
/**
42+
* Where to store a user's profile information.
43+
*/
2644
table: Table;
45+
/**
46+
* Which column to store the foreign-key relation between a user and the table where their profile information is stored.
47+
*/
2748
userRelationColumnName?: keyof (typeof schema)["users"]["$inferSelect"];
2849
}
2950

51+
/**
52+
* Helper function for defining compliant OAuth configurations.
53+
* @param configuration The OAuth configuration.
54+
* @returns `configuration`
55+
*/
3056
function OAuthProvider<
3157
const T extends Extract<(typeof schema)[keyof typeof schema], MySqlTable>,
3258
const C extends OAuthProvider<T>,
3359
>(configuration: C) {
3460
return configuration;
3561
}
3662

37-
export const google = OAuthProvider({
38-
name: "google",
63+
export const uga = OAuthProvider({
64+
name: "uga",
3965
clientId: env.AUTH_GOOGLE_ID,
4066
clientSecret: env.AUTH_GOOGLE_SECRET,
4167
consentRequest: {

src/server/db/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export const oauthStates = mysqlTable("oauth_states", (d) => ({
114114
"base64",
115115
),
116116
),
117-
realm: d.mysqlEnum(["google", "discord", "github"]).notNull(),
117+
realm: d.mysqlEnum(["uga", "discord", "github"]).notNull(),
118118
callbackPath: d.text().notNull().default("/"),
119119
createdAt: d.timestamp().defaultNow().notNull(),
120120
}));

src/server/discord/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import { REST } from "@discordjs/rest";
22
import { env } from "~/env";
33

4+
/**
5+
* @param accessToken The access token for the user.
6+
* @returns A discord.js REST API instance authenticated as a specific user.
7+
*/
48
export function asUser(accessToken: string) {
59
return new REST({
610
authPrefix: "Bearer",
711
version: "10",
812
}).setToken(accessToken);
913
}
1014

15+
/**
16+
* @returns A discord.js REST API instance authenticated as the RoboDog Discord bot.
17+
*/
1118
export function asBot() {
1219
return new REST({ version: "10" }).setToken(env.DISCORD_TOKEN);
1320
}

src/server/discord/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export function Command<T extends z.core.SomeType>(command: Command<T>) {
2525

2626
const commands = [leaderboard] satisfies ReturnType<typeof Command>[];
2727

28+
/**
29+
* Registers all commands defined above in `commands`.
30+
*/
2831
export async function registerCommands() {
2932
const response = await fetch(
3033
`https://discord.com/api/v10/applications/${env.DISCORD_CLIENT_ID}/commands`,
@@ -49,6 +52,11 @@ export async function registerCommands() {
4952
}
5053
}
5154

55+
/**
56+
* A Next.js `POST` route handler.
57+
* @param request The incoming request object.
58+
* @returns The appropriate interaction response, considering all registered commands.
59+
*/
5260
export async function handleInteractionRequest(request: NextRequest) {
5361
const signature = request.headers.get("x-signature-ed25519");
5462
const timestamp = request.headers.get("x-signature-timestamp");

src/server/github/paginate.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import { requestInit } from ".";
22

33
const nextPattern = /(?<=<)([\S]*)(?=>; rel="Next")/i;
44

5+
/**
6+
* Paginate through resources in a fetch call to the GitHub API.
7+
* @param fetchCall The initial fetch call to the GitHub API.
8+
* @returns Async generator of Responses.
9+
*/
510
export default async function* paginate(fetchCall: Promise<Response>) {
611
let next: Promise<Response> | null = fetchCall;
712

8-
while (next) {
13+
while (next !== null) {
914
const response: Response = await next;
1015
const nextUrl = response.headers.get("link")?.match(nextPattern)?.[0];
1116
yield response;

src/server/github/syncLeaderboard.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
import { sql } from "drizzle-orm";
22
import { env } from "~/env";
3+
import isInRange from "~/lib/isBetween";
34
import { requestInit } from ".";
45
import { db } from "../db";
56
import { githubProfiles } from "../db/schema";
67
import paginate from "./paginate";
78
import * as schema from "./schema";
8-
import { isBefore } from "date-fns";
9-
import isInRange from "~/lib/isBetween";
109

1110
function getProjectFields({ number }: { number: number }) {
1211
return fetch(
1312
`https://api.github.com/orgs/${env.GITHUB_ORG}/projectsV2/${number}/fields`,
1413
requestInit,
1514
)
1615
.then((res) => res.json())
17-
.then(schema.fieldsResults.parseAsync)
16+
.then((obj) => schema.fieldsResults.parseAsync(obj))
1817
.then((fields) => ({ number, fields }));
1918
}
2019

@@ -56,7 +55,9 @@ async function calculatePoints({
5655
);
5756

5857
for await (const response of pagination) {
59-
const issues = await response.json().then(schema.issueResults.parseAsync);
58+
const issues = await response
59+
.json()
60+
.then((obj) => schema.issueResults.parseAsync(obj));
6061

6162
for (const { content, fields } of issues) {
6263
const points =
@@ -79,18 +80,16 @@ async function calculatePoints({
7980
: 0),
8081
2024:
8182
(existingEntry?.points[2024] ?? 0) +
82-
(isInRange([
83-
env.AY2023_POINTS_CUTOFF,
84-
env.AY2024_POINTS_CUTOFF],
83+
(isInRange(
84+
[env.AY2023_POINTS_CUTOFF, env.AY2024_POINTS_CUTOFF],
8585
content.closed_at,
8686
)
8787
? points
8888
: 0),
8989
2025:
9090
(existingEntry?.points[2025] ?? 0) +
91-
(isInRange([
92-
env.AY2024_POINTS_CUTOFF,
93-
env.AY2025_POINTS_CUTOFF],
91+
(isInRange(
92+
[env.AY2024_POINTS_CUTOFF, env.AY2025_POINTS_CUTOFF],
9493
content.closed_at,
9594
)
9695
? points
@@ -130,7 +129,7 @@ export default function syncLeaderboard() {
130129
requestInit,
131130
)
132131
.then((res) => res.json())
133-
.then(schema.projectResults.parseAsync)
132+
.then((obj) => schema.projectResults.parseAsync(obj))
134133
.then((projects) => Promise.allSettled(projects.map(getProjectFields)))
135134
.then((results) =>
136135
Promise.allSettled(

src/server/utilts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const callbackPathSchema = z.string().transform((path, ctx) => {
1616
}
1717
});
1818

19+
/**
20+
* Extracts a callback path from either the `callbackPath` field in `formData` or the `referer` field in the incoming request headers.
21+
* @param fallback Used if `callbackPath` is not present in `formData` or `referer` is not present in `headers()`.
22+
* @param formData A `FormData` obect.
23+
* @returns The extracted callback path.
24+
*/
1925
export async function getCallbackPath(
2026
fallback: string,
2127
formData?: FormData,

0 commit comments

Comments
 (0)