Skip to content

Commit 1f2ba56

Browse files
somereticalYankai Zhu
andauthored
[I2-17] Added backend events API (#53)
* Added backend events API * polished code --------- Co-authored-by: Yankai Zhu <[email protected]>
1 parent 911b8f7 commit 1f2ba56

File tree

13 files changed

+386
-10
lines changed

13 files changed

+386
-10
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ yarn-error.log*
3535
# typescript
3636
*.tsbuildinfo
3737
next-env.d.ts
38+
39+
*.env

backend/package-lock.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"@types/cors": "^2.8.17",
1010
"@types/express": "^4.17.21",
1111
"@types/node": "^20.12.13",
12+
"async-mutex": "^0.5.0",
1213
"cors": "^2.8.5",
1314
"dotenv": "^16.4.5",
1415
"express": "^4.19.2",

backend/src/controllers/events.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { RequestHandler } from "express";
2+
import { eventInfo } from "../data/eventData";
3+
4+
export const EventsHandler: RequestHandler = (req, res) => {
5+
res.status(200).json(eventInfo);
6+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import crypto from "crypto";
2+
import { RequestHandler } from "express";
3+
import { eventInfo, eventInfoMutex, fetchEvent } from "../data/eventData";
4+
import { filterInPlace, replaceInPlace } from "../util";
5+
6+
interface FacebookWebhookPayload {
7+
object: string;
8+
entry: Array<{
9+
id: string;
10+
changes: Array<{
11+
field: string;
12+
value: {
13+
event_id: string;
14+
item: string;
15+
verb: string;
16+
};
17+
}>;
18+
}>;
19+
}
20+
21+
const verifySignature = (
22+
rawBody: Buffer,
23+
signatureHeader?: string
24+
): boolean => {
25+
if (!signatureHeader) return false;
26+
const [algo, signature] = signatureHeader.split("=");
27+
if (algo !== "sha256") return false;
28+
29+
const expected = crypto
30+
.createHmac("sha256", process.env.FB_APP_SECRET as string)
31+
.update(rawBody)
32+
.digest("hex");
33+
34+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
35+
};
36+
37+
export const EventsWebhookVerifier: RequestHandler = (req, res) => {
38+
const mode = req.query["hub.mode"];
39+
const token = req.query["hub.verify_token"];
40+
const challenge = req.query["hub.challenge"];
41+
42+
if (mode === "subscribe" && token === process.env.FB_WEBHOOK_VERIFY_TOKEN) {
43+
return res.status(200).send(challenge);
44+
}
45+
46+
res.sendStatus(403);
47+
};
48+
49+
/*
50+
Sample webhook payload
51+
https://developers.facebook.com/docs/graph-api/webhooks/getting-started/webhooks-for-pages -- for the outer wrapper
52+
https://developers.facebook.com/docs/graph-api/webhooks/reference/page/#feed -- for the inner objects
53+
54+
{
55+
"object": "page",
56+
"entry": [
57+
{
58+
"id": "PAGE_ID",
59+
"time": 1623242342342,
60+
"changes": [
61+
{
62+
"field": "events",
63+
"value": {
64+
"event_id": "123456789",
65+
"verb": "create", // also "edit" or "delete"
66+
"published": 1
67+
}
68+
}
69+
]
70+
}
71+
]
72+
}
73+
*/
74+
75+
export const EventsWebhookUpdate: RequestHandler = async (req, res) => {
76+
const signature = req.headers["x-hub-signature-256"];
77+
if (
78+
!req.rawBody ||
79+
typeof signature !== "string" ||
80+
!verifySignature(req.rawBody, signature)
81+
) {
82+
return res.sendStatus(401);
83+
}
84+
85+
const notif: FacebookWebhookPayload = req.body;
86+
if (
87+
!notif ||
88+
!notif.entry ||
89+
notif.object !== "page" ||
90+
notif.entry.length === 0
91+
) {
92+
return res.sendStatus(400);
93+
}
94+
95+
for (const entry of notif.entry) {
96+
if (entry.id !== process.env.FB_EVENT_PAGE_ID) continue;
97+
98+
for (const change of entry.changes) {
99+
if (change.field !== "feed" || change.value.item !== "event") continue;
100+
101+
try {
102+
if (change.value.verb === "delete") {
103+
await eventInfoMutex.runExclusive(() =>
104+
filterInPlace(eventInfo, (val) => val.id !== change.value.event_id)
105+
);
106+
console.log(`Deleted event: ${change.value.event_id}`);
107+
} else if (change.value.verb === "edit") {
108+
const newEvent = await fetchEvent(change.value.event_id);
109+
110+
eventInfoMutex.runExclusive(() =>
111+
replaceInPlace(
112+
eventInfo,
113+
(val) => val.id === change.value.event_id,
114+
newEvent
115+
)
116+
);
117+
console.log(`Edited event: ${change.value.event_id}`);
118+
} else if (change.value.verb === "add") {
119+
const newEvent = await fetchEvent(change.value.event_id);
120+
await eventInfoMutex.runExclusive(() => eventInfo.push(newEvent));
121+
console.log(`Added event: ${change.value.event_id}`);
122+
} else {
123+
console.warn(
124+
`Unknown verb "${change.value.verb}" for event ${change.value.event_id}`
125+
);
126+
}
127+
} catch (err) {
128+
console.error(
129+
`Error processing event: ${change.value.event_id}:\n${err}`
130+
);
131+
return res.sendStatus(500);
132+
}
133+
}
134+
}
135+
136+
res.sendStatus(200);
137+
};

backend/src/data/eventData.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Mutex } from "async-mutex";
2+
import { inspect } from "util";
3+
import { FacebookError, Result, ResultType } from "../util";
4+
5+
class EventInfo {
6+
// god forbid a class have public members
7+
public id: string;
8+
public title: string;
9+
public startTime: string;
10+
public endTime?: string;
11+
public location: string;
12+
public imageUrl: string;
13+
public link: string;
14+
15+
constructor(
16+
id: string,
17+
title: string,
18+
startTime: string,
19+
endTime: string | undefined,
20+
location: string,
21+
imageUrl: string
22+
) {
23+
this.id = id;
24+
this.title = title;
25+
this.startTime = startTime;
26+
this.endTime = endTime;
27+
this.location = location;
28+
this.imageUrl = imageUrl;
29+
// would use link as getter but getters are not enumerable so it doesn't appear in JSON.stringify :skull:
30+
// maybe a cursed fix would be to use Object.defineProperty LOL
31+
this.link = `https://www.facebook.com/events/${id}`;
32+
}
33+
}
34+
35+
interface FacebookEvent {
36+
id: string;
37+
name: string;
38+
cover?: { source: string };
39+
place?: { name: string };
40+
start_time: string;
41+
end_time?: string;
42+
}
43+
44+
interface FacebookEventsResponse {
45+
data: FacebookEvent[];
46+
}
47+
48+
// this isn't in .env for different module compatiblity
49+
const FB_API_VERSION = "v23.0";
50+
const DEFAULT_EVENT_LOCATION = "Everything everywhere all at once!!!";
51+
const DEFAULT_EVENT_IMAGE = "/images/events/default_event.jpg";
52+
53+
// we LOVE global variables
54+
export const eventInfoMutex = new Mutex();
55+
export const eventInfo: EventInfo[] = [];
56+
57+
export async function fetchEvents() {
58+
const response = await fetch(
59+
`https://graph.facebook.com/${FB_API_VERSION}/${process.env.FB_EVENT_PAGE_ID}/events?access_token=${process.env.FB_ACCESS_TOKEN}&fields=id,name,cover,place,start_time,end_time`
60+
);
61+
62+
const res: Result<FacebookEventsResponse, FacebookError> = await response.json();
63+
if (!res || res.type === ResultType.Err) {
64+
console.log(`No events found...\n${res}`);
65+
return [];
66+
}
67+
68+
const processed = res.value.data.map(
69+
(e) =>
70+
new EventInfo(
71+
e.id,
72+
e.name,
73+
e.start_time,
74+
e.end_time,
75+
e.place?.name ?? DEFAULT_EVENT_LOCATION,
76+
e.cover?.source ?? DEFAULT_EVENT_IMAGE
77+
)
78+
);
79+
80+
return processed;
81+
}
82+
83+
export async function fetchEvent(id: string) {
84+
const response = await fetch(
85+
`https://graph.facebook.com/${FB_API_VERSION}/${id}?access_token=${process.env.FB_ACCESS_TOKEN}&fields=id,name,cover,place,start_time,end_time`
86+
);
87+
88+
const res: Result<FacebookEvent, FacebookError> = await response.json();
89+
90+
if (!res || res.type === ResultType.Err) {
91+
throw new Error(
92+
`Couldn't fetch details for event ${id}\n${inspect(
93+
Object.getOwnPropertyDescriptor(res, "error")?.value
94+
)}`
95+
);
96+
}
97+
98+
return new EventInfo(
99+
res.value.id,
100+
res.value.name,
101+
res.value.start_time,
102+
res.value.end_time,
103+
res.value.place?.name ?? DEFAULT_EVENT_LOCATION,
104+
res.value.cover?.source ?? DEFAULT_EVENT_IMAGE
105+
);
106+
}

backend/src/index.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,41 @@ import express, { Express } from "express";
22
import cors from "cors";
33
import dotenv from "dotenv";
44
import pingRoute from "./routes/ping";
5+
import eventsRoute from "./routes/events";
6+
import eventsWebhookRoute from "./routes/eventsWebhook";
7+
import { eventInfo, eventInfoMutex, fetchEvents } from "./data/eventData";
58

69
dotenv.config();
710

8-
const app: Express = express();
9-
const port = process.env.PORT || 9000;
11+
(async () => {
12+
try {
13+
const events = await fetchEvents();
14+
eventInfoMutex.runExclusive(() => eventInfo.concat(events));
15+
console.log("Events fetched successfully");
16+
} catch (error) {
17+
// do we ungracefully bail out here???
18+
// could just load from a backup file instead
19+
console.error("Error fetching events on startup:", error);
20+
}
1021

11-
// Middleware
12-
app.use(express.json());
13-
app.use(cors());
22+
const app: Express = express();
23+
const port = process.env.PORT || 9000;
1424

15-
app.use(pingRoute);
25+
// Middleware
26+
app.use(
27+
express.json({
28+
verify: (req, res, buf) => {
29+
req.rawBody = buf;
30+
},
31+
})
32+
);
33+
app.use(cors());
1634

17-
app.listen(port, () => {
18-
console.log(`Server successfully started on port ${port}`);
19-
});
35+
app.use(pingRoute);
36+
app.use(eventsWebhookRoute);
37+
app.use(eventsRoute);
38+
39+
app.listen(port, () => {
40+
console.log(`Server successfully started on port ${port}`);
41+
});
42+
})();

backend/src/routes/events.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Router } from "express";
2+
import { EventsHandler } from "../controllers/events";
3+
4+
const router = Router();
5+
6+
router.get("/events", EventsHandler);
7+
8+
export default router;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Router } from "express";
2+
import { EventsWebhookUpdate, EventsWebhookVerifier } from "../controllers/eventsWebhook";
3+
4+
const router = Router();
5+
6+
router.post("/eventsWebhook", EventsWebhookUpdate);
7+
router.get("/eventsWebhook", EventsWebhookVerifier);
8+
9+
export default router;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import "express";
2+
3+
declare module "express-serve-static-core" {
4+
interface Request {
5+
rawBody?: Buffer;
6+
}
7+
8+
interface IncomingMessage {
9+
rawBody?: Buffer;
10+
}
11+
}

0 commit comments

Comments
 (0)