Skip to content

Commit 6e0afc8

Browse files
committed
functionality
1 parent 33f69bd commit 6e0afc8

File tree

16 files changed

+1012
-7
lines changed

16 files changed

+1012
-7
lines changed

src/api/functions/entraId.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "../../common/config.js";
88
import {
99
BaseError,
10+
EntraFetchError,
1011
EntraGroupError,
1112
EntraInvitationError,
1213
InternalServerError,
@@ -19,6 +20,7 @@ import {
1920
EntraInvitationResponse,
2021
} from "../../common/types/iam.js";
2122
import { FastifyInstance } from "fastify";
23+
import { UserProfileDataBase } from "common/types/msGraphApi.js";
2224

2325
function validateGroupId(groupId: string): boolean {
2426
const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed
@@ -351,3 +353,47 @@ export async function listGroupMembers(
351353
});
352354
}
353355
}
356+
357+
/**
358+
* Retrieves the profile of a user from Entra ID.
359+
* @param token - Entra ID token authorized to perform this action.
360+
* @param userId - The user ID to fetch the profile for.
361+
* @throws {EntraUserError} If fetching the user profile fails.
362+
* @returns {Promise<UserProfileDataBase>} The user's profile information.
363+
*/
364+
export async function getUserProfile(
365+
token: string,
366+
email: string,
367+
): Promise<UserProfileDataBase> {
368+
const userId = await resolveEmailToOid(token, email);
369+
try {
370+
const url = `https://graph.microsoft.com/v1.0/users/${userId}?$select=userPrincipalName,givenName,surname,displayName,otherMails,mail`;
371+
const response = await fetch(url, {
372+
method: "GET",
373+
headers: {
374+
Authorization: `Bearer ${token}`,
375+
"Content-Type": "application/json",
376+
},
377+
});
378+
379+
if (!response.ok) {
380+
const errorData = (await response.json()) as {
381+
error?: { message?: string };
382+
};
383+
throw new EntraFetchError({
384+
message: errorData?.error?.message ?? response.statusText,
385+
email,
386+
});
387+
}
388+
return (await response.json()) as UserProfileDataBase;
389+
} catch (error) {
390+
if (error instanceof EntraFetchError) {
391+
throw error;
392+
}
393+
394+
throw new EntraFetchError({
395+
message: error instanceof Error ? error.message : String(error),
396+
email,
397+
});
398+
}
399+
}

src/api/functions/membership.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { FastifyBaseLogger, FastifyInstance } from "fastify";
2+
3+
export async function checkPaidMembership(
4+
endpoint: string,
5+
log: FastifyBaseLogger,
6+
netId: string,
7+
) {
8+
const membershipApiPayload = (await (
9+
await fetch(`${endpoint}?netId=${netId}`)
10+
).json()) as { netId: string; isPaidMember: boolean };
11+
log.trace(`Got Membership API Payload for ${netId}: ${membershipApiPayload}`);
12+
try {
13+
return membershipApiPayload["isPaidMember"];
14+
} catch (e: any) {
15+
log.error(`Failed to get response from membership API: ${e.toString()}`);
16+
throw e;
17+
}
18+
}

src/api/functions/mobileWallet.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { getSecretValue } from "../plugins/auth.js";
2+
import { genericConfig, SecretConfig } from "../../common/config.js";
3+
import {
4+
InternalServerError,
5+
UnauthorizedError,
6+
} from "../../common/errors/index.js";
7+
import { FastifyInstance, FastifyRequest } from "fastify";
8+
// these make sure that esbuild includes the files
9+
import icon from "../resources/MembershipPass.pkpass/icon.png";
10+
import logo from "../resources/MembershipPass.pkpass/logo.png";
11+
import strip from "../resources/MembershipPass.pkpass/strip.png";
12+
import pass from "../resources/MembershipPass.pkpass/pass.js";
13+
import { PKPass } from "passkit-generator";
14+
import { promises as fs } from "fs";
15+
16+
function trim(s: string) {
17+
return (s || "").replace(/^\s+|\s+$/g, "");
18+
}
19+
20+
function convertName(name: string): string {
21+
if (!name.includes(",")) {
22+
return name;
23+
}
24+
return `${trim(name.split(",")[1])} ${name.split(",")[0]}`;
25+
}
26+
27+
export async function issueAppleWalletMembershipCard(
28+
app: FastifyInstance,
29+
request: FastifyRequest,
30+
email: string,
31+
name?: string,
32+
) {
33+
if (!email.endsWith("@illinois.edu")) {
34+
throw new UnauthorizedError({
35+
message:
36+
"Cannot issue membership pass for emails not on the illinois.edu domain.",
37+
});
38+
}
39+
const secretApiConfig = (await getSecretValue(
40+
app.secretsManagerClient,
41+
genericConfig.ConfigSecretName,
42+
)) as SecretConfig;
43+
if (!secretApiConfig) {
44+
throw new InternalServerError({
45+
message: "Could not retrieve signing data",
46+
});
47+
}
48+
const signerCert = Buffer.from(
49+
secretApiConfig.acm_passkit_signerCert_base64,
50+
"base64",
51+
).toString("utf-8");
52+
const signerKey = Buffer.from(
53+
secretApiConfig.acm_passkit_signerKey_base64,
54+
"base64",
55+
).toString("utf-8");
56+
const wwdr = Buffer.from(
57+
secretApiConfig.apple_signing_cert_base64,
58+
"base64",
59+
).toString("utf-8");
60+
pass["passTypeIdentifier"] = app.environmentConfig["PasskitIdentifier"];
61+
62+
const pkpass = new PKPass(
63+
{
64+
"icon.png": await fs.readFile(icon),
65+
"logo.png": await fs.readFile(logo),
66+
"strip.png": await fs.readFile(strip),
67+
"pass.json": Buffer.from(JSON.stringify(pass)),
68+
},
69+
{
70+
wwdr,
71+
signerCert,
72+
signerKey,
73+
},
74+
{
75+
// logoText: app.runEnvironment === "dev" ? "INVALID Membership Pass" : "Membership Pass",
76+
serialNumber: app.environmentConfig["PasskitSerialNumber"],
77+
},
78+
);
79+
pkpass.setBarcodes({
80+
altText: email.split("@")[0],
81+
format: "PKBarcodeFormatPDF417",
82+
message: app.runEnvironment === "dev" ? `INVALID${email}INVALID` : email,
83+
});
84+
const iat = new Date().toLocaleDateString("en-US", {
85+
day: "2-digit",
86+
month: "2-digit",
87+
year: "numeric",
88+
});
89+
if (name && name !== "") {
90+
pkpass.secondaryFields.push({
91+
label: "Member Name",
92+
key: "name",
93+
value: convertName(name),
94+
});
95+
}
96+
if (app.runEnvironment === "prod") {
97+
pkpass.backFields.push({
98+
label: "Verification URL",
99+
key: "iss",
100+
value: `https://membership.acm.illinois.edu/verify/${email.split("@")[0]}`,
101+
});
102+
} else {
103+
pkpass.backFields.push({
104+
label: "TESTING ONLY Pass",
105+
key: "iss",
106+
value: `Do not honor!`,
107+
});
108+
}
109+
pkpass.backFields.push({ label: "Pass Created On", key: "iat", value: iat });
110+
pkpass.backFields.push({ label: "Membership ID", key: "id", value: email });
111+
const buffer = pkpass.getAsBuffer();
112+
request.log.info(
113+
{ type: "audit", actor: email, target: email },
114+
"Created membership verification pass",
115+
);
116+
return buffer;
117+
}

src/api/functions/ses.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { SendRawEmailCommand } from "@aws-sdk/client-ses";
2+
import { encode } from "base64-arraybuffer";
3+
4+
/**
5+
* Generates a SendRawEmailCommand for SES to send an email with an attached membership pass.
6+
*
7+
* @param recipientEmail - The email address of the recipient.
8+
* * @param recipientEmail - The email address of the sender with a verified identity in SES.
9+
* @param attachmentBuffer - The membership pass in ArrayBufferLike format.
10+
* @returns The command to send the email via SES.
11+
*/
12+
export function generateMembershipEmailCommand(
13+
recipientEmail: string,
14+
senderEmail: string,
15+
attachmentBuffer: ArrayBufferLike,
16+
): SendRawEmailCommand {
17+
const encodedAttachment = encode(attachmentBuffer);
18+
const boundary = "----BoundaryForEmail";
19+
20+
const emailTemplate = `
21+
<!doctype html>
22+
<html>
23+
<head>
24+
<title>Your ACM @ UIUC Membership</title>
25+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
26+
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
27+
<base target="_blank">
28+
<style>
29+
body {
30+
background-color: #F0F1F3;
31+
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;
32+
font-size: 15px;
33+
line-height: 26px;
34+
margin: 0;
35+
color: #444;
36+
}
37+
.wrap {
38+
background-color: #fff;
39+
padding: 30px;
40+
max-width: 525px;
41+
margin: 0 auto;
42+
border-radius: 5px;
43+
}
44+
.button {
45+
background: #0055d4;
46+
border-radius: 3px;
47+
text-decoration: none !important;
48+
color: #fff !important;
49+
font-weight: bold;
50+
padding: 10px 30px;
51+
display: inline-block;
52+
}
53+
.button:hover {
54+
background: #111;
55+
}
56+
.footer {
57+
text-align: center;
58+
font-size: 12px;
59+
color: #888;
60+
}
61+
img {
62+
max-width: 100%;
63+
height: auto;
64+
}
65+
a {
66+
color: #0055d4;
67+
}
68+
a:hover {
69+
color: #111;
70+
}
71+
@media screen and (max-width: 600px) {
72+
.wrap {
73+
max-width: auto;
74+
}
75+
}
76+
</style>
77+
</head>
78+
<body>
79+
<div class="gutter" style="padding: 30px;">&nbsp;</div>
80+
<img src="https://acm-brand-images.s3.amazonaws.com/banner-blue.png" style="height: 100px; width: 210px; align-self: center;"/>
81+
<br />
82+
<div class="wrap">
83+
<h2 style="text-align: center;">Welcome</h2>
84+
<p>
85+
Thank you for becoming a member of ACM @ UIUC! Attached is your membership pass.
86+
You can add it to your Apple or Google Wallet for easy access.
87+
</p>
88+
<p>
89+
If you have any questions, feel free to contact us at
90+
<a href="mailto:[email protected]">[email protected]</a>.
91+
</p>
92+
<div style="text-align: center; margin-top: 20px;">
93+
<a href="https://acm.illinois.edu" class="button">Visit ACM @ UIUC</a>
94+
</div>
95+
</div>
96+
<div class="footer">
97+
<p>
98+
<a href="https://acm.illinois.edu">ACM @ UIUC Homepage</a>
99+
<a href="mailto:[email protected]">Email ACM @ UIUC</a>
100+
</p>
101+
</div>
102+
</body>
103+
</html>
104+
`;
105+
106+
const rawEmail = `
107+
MIME-Version: 1.0
108+
Content-Type: multipart/mixed; boundary="${boundary}"
109+
From: ACM @ UIUC <${senderEmail}>
110+
To: ${recipientEmail}
111+
Subject: Your ACM @ UIUC Membership
112+
113+
--${boundary}
114+
Content-Type: text/html; charset="UTF-8"
115+
116+
${emailTemplate}
117+
118+
--${boundary}
119+
Content-Type: application/vnd.apple.pkpass
120+
Content-Transfer-Encoding: base64
121+
Content-Disposition: attachment; filename="membership.pkpass"
122+
123+
${encodedAttachment}
124+
--${boundary}--`.trim();
125+
return new SendRawEmailCommand({
126+
RawMessage: {
127+
Data: new TextEncoder().encode(rawEmail),
128+
},
129+
});
130+
}

src/api/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
2121
import NodeCache from "node-cache";
2222
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2323
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
24+
import { SESClient } from "@aws-sdk/client-ses";
2425

2526
dotenv.config();
2627

@@ -35,6 +36,10 @@ async function init() {
3536
region: genericConfig.AwsRegion,
3637
});
3738

39+
const sesClient = new SESClient({
40+
region: genericConfig.AwsRegion,
41+
});
42+
3843
const app: FastifyInstance = fastify({
3944
logger: {
4045
level: process.env.LOG_LEVEL || "info",
@@ -82,6 +87,7 @@ async function init() {
8287
app.nodeCache = new NodeCache({ checkperiod: 30 });
8388
app.dynamoClient = dynamoClient;
8489
app.secretsManagerClient = secretsManagerClient;
90+
app.sesClient = sesClient;
8591
app.addHook("onRequest", (req, _, done) => {
8692
req.startTime = now();
8793
const hostname = req.hostname;

src/api/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"dependencies": {
1818
"@aws-sdk/client-dynamodb": "^3.624.0",
1919
"@aws-sdk/client-secrets-manager": "^3.624.0",
20+
"@aws-sdk/client-ses": "^3.734.0",
2021
"@aws-sdk/client-sts": "^3.726.0",
2122
"@aws-sdk/util-dynamodb": "^3.624.0",
2223
"@azure/msal-node": "^2.16.1",
@@ -25,6 +26,7 @@
2526
"@fastify/caching": "^9.0.1",
2627
"@fastify/cors": "^10.0.1",
2728
"@touch4it/ical-timezones": "^1.9.0",
29+
"base64-arraybuffer": "^1.0.2",
2830
"discord.js": "^14.15.3",
2931
"dotenv": "^16.4.5",
3032
"esbuild": "^0.24.2",
@@ -36,6 +38,7 @@
3638
"moment": "^2.30.1",
3739
"moment-timezone": "^0.5.45",
3840
"node-cache": "^5.1.2",
41+
"passkit-generator": "^3.3.1",
3942
"pluralize": "^8.0.0",
4043
"zod": "^3.23.8",
4144
"zod-to-json-schema": "^3.23.2",
@@ -44,4 +47,4 @@
4447
"devDependencies": {
4548
"@tsconfig/node22": "^22.0.0"
4649
}
47-
}
50+
}
8.12 KB
Loading
27.5 KB
Loading

0 commit comments

Comments
 (0)