diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec916ca..46d5824 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,10 +4,12 @@ on: push: branches: - master + - qa - renovate/* pull_request: branches: - master + - qa jobs: api-ci: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d36bebb..d9d648e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,6 +3,7 @@ on: push: branches: - "master" + - "qa" jobs: build: @@ -34,21 +35,21 @@ jobs: uses: docker/build-push-action@v5 with: context: ${{ matrix.component }} - push: ${{ github.event_name != 'pull_request' }} + push: ${{ github.event_name != 'pull_request' && ( github.ref == 'refs/heads/master' || github.ref == 'refs/heads/qa' ) }} platforms: linux/amd64 file: ${{ matrix.component }}/Dockerfile tags: | ghcr.io/csesoc/website-${{ matrix.component }}:${{ github.sha }} ghcr.io/csesoc/website-${{ matrix.component }}:latest labels: ${{ steps.meta.outputs.labels }} - deploy-prod: - name: Deploy Production (CD) + deploy: + name: Deploy (CD) runs-on: ubuntu-latest needs: [build] concurrency: prod environment: name: prod - if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/master' }} + if: ${{ github.event_name != 'pull_request' && ( github.ref == 'refs/heads/master' || github.ref == 'refs/heads/qa' ) }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -58,17 +59,29 @@ jobs: ref: develop - name: Install yq - portable yaml processor uses: mikefarah/yq@v4.27.2 + - name: "Determine deployment type" + id: get_manifest + env: + BRANCH: ${{ github.ref }} + run: | + if [[ "${{ github.ref }}" == "refs/heads/master" ]]; then + echo "TYPE=prod" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == "refs/heads/qa" ]]; then + echo "TYPE=qa" >> $GITHUB_OUTPUT + else + exit 1 + fi - name: Update deployment env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} run: | git config user.name "CSESoc CD" git config user.email "technical@csesoc.org.au" - git checkout -b update/website-prod/${{ github.sha }} - yq -i '.items[0].spec.template.spec.containers[0].image = "ghcr.io/csesoc/website-backend:${{ github.sha }}"' apps/projects/website/prod/deploy-backend.yml - yq -i '.items[0].spec.template.spec.containers[0].image = "ghcr.io/csesoc/website-frontend:${{ github.sha }}"' apps/projects/website/prod/deploy-frontend.yml + git checkout -b update/website-${{ steps.get_manifest.outputs.TYPE }}/${{ github.sha }} + yq -i '.items[0].spec.template.spec.containers[0].image = "ghcr.io/csesoc/website-backend:${{ github.sha }}"' apps/projects/website/${{ steps.get_manifest.outputs.TYPE }}/deploy-backend.yml + yq -i '.items[0].spec.template.spec.containers[0].image = "ghcr.io/csesoc/website-frontend:${{ github.sha }}"' apps/projects/website/${{ steps.get_manifest.outputs.TYPE }}/deploy-frontend.yml git add . - git commit -m "feat(website/prod): update image" - git push -u origin update/website-prod/${{ github.sha }} - gh pr create -B develop --title "feat(website/prod): update image" --body "Updates the image for the website-prod deployment to commit csesoc/csesoc-website@${{ github.sha }}." > URL + git commit -m "feat(website/${{ steps.get_manifest.outputs.TYPE }}): update image" + git push -u origin update/website-${{ steps.get_manifest.outputs.TYPE }}/${{ github.sha }} + gh pr create -B develop --title "feat(website/${{ steps.get_manifest.outputs.TYPE }}): update image" --body "Updates the image for the website-prod deployment to commit csesoc/csesoc-website@${{ github.sha }}." > URL gh pr merge $(cat URL) --squash -d diff --git a/.gitignore b/.gitignore index 8d2da39..a437c64 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +*.env \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index f07f2f9..d96a388 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.12.13", + "async-mutex": "^0.5.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -230,6 +231,14 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1274,6 +1283,11 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/backend/package.json b/backend/package.json index c7532f0..473c29b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.12.13", + "async-mutex": "^0.5.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", diff --git a/backend/src/controllers/events.ts b/backend/src/controllers/events.ts new file mode 100644 index 0000000..6ca0d96 --- /dev/null +++ b/backend/src/controllers/events.ts @@ -0,0 +1,6 @@ +import { RequestHandler } from "express"; +import { eventInfo } from "../data/eventData"; + +export const EventsHandler: RequestHandler = (req, res) => { + res.status(200).json(eventInfo); +} \ No newline at end of file diff --git a/backend/src/controllers/eventsWebhook.ts b/backend/src/controllers/eventsWebhook.ts new file mode 100644 index 0000000..f45c768 --- /dev/null +++ b/backend/src/controllers/eventsWebhook.ts @@ -0,0 +1,137 @@ +import crypto from "crypto"; +import { RequestHandler } from "express"; +import { eventInfo, eventInfoMutex, fetchEvent } from "../data/eventData"; +import { filterInPlace, replaceInPlace } from "../util"; + +interface FacebookWebhookPayload { + object: string; + entry: Array<{ + id: string; + changes: Array<{ + field: string; + value: { + event_id: string; + item: string; + verb: string; + }; + }>; + }>; +} + +const verifySignature = ( + rawBody: Buffer, + signatureHeader?: string +): boolean => { + if (!signatureHeader) return false; + const [algo, signature] = signatureHeader.split("="); + if (algo !== "sha256") return false; + + const expected = crypto + .createHmac("sha256", process.env.FB_APP_SECRET as string) + .update(rawBody) + .digest("hex"); + + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); +}; + +export const EventsWebhookVerifier: RequestHandler = (req, res) => { + const mode = req.query["hub.mode"]; + const token = req.query["hub.verify_token"]; + const challenge = req.query["hub.challenge"]; + + if (mode === "subscribe" && token === process.env.FB_WEBHOOK_VERIFY_TOKEN) { + return res.status(200).send(challenge); + } + + res.sendStatus(403); +}; + +/* +Sample webhook payload +https://developers.facebook.com/docs/graph-api/webhooks/getting-started/webhooks-for-pages -- for the outer wrapper +https://developers.facebook.com/docs/graph-api/webhooks/reference/page/#feed -- for the inner objects + +{ + "object": "page", + "entry": [ + { + "id": "PAGE_ID", + "time": 1623242342342, + "changes": [ + { + "field": "events", + "value": { + "event_id": "123456789", + "verb": "create", // also "edit" or "delete" + "published": 1 + } + } + ] + } + ] +} +*/ + +export const EventsWebhookUpdate: RequestHandler = async (req, res) => { + const signature = req.headers["x-hub-signature-256"]; + if ( + !req.rawBody || + typeof signature !== "string" || + !verifySignature(req.rawBody, signature) + ) { + return res.sendStatus(401); + } + + const notif: FacebookWebhookPayload = req.body; + if ( + !notif || + !notif.entry || + notif.object !== "page" || + notif.entry.length === 0 + ) { + return res.sendStatus(400); + } + + for (const entry of notif.entry) { + if (entry.id !== process.env.FB_EVENT_PAGE_ID) continue; + + for (const change of entry.changes) { + if (change.field !== "feed" || change.value.item !== "event") continue; + + try { + if (change.value.verb === "delete") { + await eventInfoMutex.runExclusive(() => + filterInPlace(eventInfo, (val) => val.id !== change.value.event_id) + ); + console.log(`Deleted event: ${change.value.event_id}`); + } else if (change.value.verb === "edit") { + const newEvent = await fetchEvent(change.value.event_id); + + eventInfoMutex.runExclusive(() => + replaceInPlace( + eventInfo, + (val) => val.id === change.value.event_id, + newEvent + ) + ); + console.log(`Edited event: ${change.value.event_id}`); + } else if (change.value.verb === "add") { + const newEvent = await fetchEvent(change.value.event_id); + await eventInfoMutex.runExclusive(() => eventInfo.push(newEvent)); + console.log(`Added event: ${change.value.event_id}`); + } else { + console.warn( + `Unknown verb "${change.value.verb}" for event ${change.value.event_id}` + ); + } + } catch (err) { + console.error( + `Error processing event: ${change.value.event_id}:\n${err}` + ); + return res.sendStatus(500); + } + } + } + + res.sendStatus(200); +}; diff --git a/backend/src/data/eventData.ts b/backend/src/data/eventData.ts new file mode 100644 index 0000000..0ca44a8 --- /dev/null +++ b/backend/src/data/eventData.ts @@ -0,0 +1,101 @@ +import { Mutex } from "async-mutex"; +import { inspect } from "util"; +import { FacebookError, Result, ResultType } from "../util"; + +class EventInfo { + // god forbid a class have public members + public id: string; + public title: string; + public startTime: string; + public endTime?: string; + public location: string; + public imageUrl: string; + public link: string; + + constructor( + id: string, + title: string, + startTime: string, + endTime: string | undefined, + location: string, + imageUrl: string + ) { + this.id = id; + this.title = title; + this.startTime = startTime; + this.endTime = endTime; + this.location = location; + this.imageUrl = imageUrl; + // would use link as getter but getters are not enumerable so it doesn't appear in JSON.stringify :skull: + // maybe a cursed fix would be to use Object.defineProperty LOL + this.link = `https://www.facebook.com/events/${id}`; + } +} + +interface FacebookEvent { + id: string; + name: string; + cover?: { source: string }; + place?: { name: string }; + start_time: string; + end_time?: string; +} + +interface FacebookEventsResponse { + data: FacebookEvent[]; +} + +// this isn't in .env for different module compatiblity +const FB_API_VERSION = "v23.0"; +const DEFAULT_EVENT_LOCATION = "Everything everywhere all at once!!!"; +const DEFAULT_EVENT_IMAGE = "/images/events/default_event.jpg"; + +// we LOVE global variables +export const eventInfoMutex = new Mutex(); +export const eventInfo: EventInfo[] = []; + +export async function fetchEvents() { + const response = await fetch( + `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` + ); + + if (!response.ok) { + throw new Error(JSON.stringify(response.json())); + + } + const res: FacebookEventsResponse = await response.json(); + + const processed = res.data.map( + (e) => + new EventInfo( + e.id, + e.name, + e.start_time, + e.end_time, + e.place?.name ?? DEFAULT_EVENT_LOCATION, + e.cover?.source ?? DEFAULT_EVENT_IMAGE + ) + ); + + return processed; +} + +export async function fetchEvent(id: string) { + const response = await fetch( + `https://graph.facebook.com/${FB_API_VERSION}/${id}?access_token=${process.env.FB_ACCESS_TOKEN}&fields=id,name,cover,place,start_time,end_time` + ); + + if (!response.ok) { + throw new Error(`Couldn't fetch details for event ${id}\n${JSON.stringify(response.json())}`); + } + const res: FacebookEvent = await response.json(); + + return new EventInfo( + res.id, + res.name, + res.start_time, + res.end_time, + res.place?.name ?? DEFAULT_EVENT_LOCATION, + res.cover?.source ?? DEFAULT_EVENT_IMAGE + ); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index d1b845a..9daf696 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,18 +2,46 @@ import express, { Express } from "express"; import cors from "cors"; import dotenv from "dotenv"; import pingRoute from "./routes/ping"; +import eventsRoute from "./routes/events"; +import eventsWebhookRoute from "./routes/eventsWebhook"; +import { eventInfo, eventInfoMutex, fetchEvents } from "./data/eventData"; dotenv.config(); -const app: Express = express(); -const port = process.env.PORT || 9000; +(async () => { + setInterval(async () => { + try { + const events = await fetchEvents(); + eventInfoMutex.runExclusive(() => { + eventInfo.length = 0; + eventInfo.push(...events); + }); + console.log("Events fetched successfully"); + } catch (error) { + // do we ungracefully bail out here??? + // could just load from a backup file instead + console.error("Error fetching events:", error); + } + }, 30 * 60 * 1000); -// Middleware -app.use(express.json()); -app.use(cors()); + const app: Express = express(); + const port = process.env.PORT || 9000; -app.use(pingRoute); + // Middleware + app.use( + express.json({ + verify: (req, res, buf) => { + req.rawBody = buf; + }, + }) + ); + app.use(cors()); -app.listen(port, () => { - console.log(`Server successfully started on port ${port}`); -}); \ No newline at end of file + app.use(pingRoute); + app.use(eventsWebhookRoute); + app.use(eventsRoute); + + app.listen(port, () => { + console.log(`Server successfully started on port ${port}`); + }); +})(); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts new file mode 100644 index 0000000..10b6f8f --- /dev/null +++ b/backend/src/routes/events.ts @@ -0,0 +1,8 @@ +import { Router } from "express"; +import { EventsHandler } from "../controllers/events"; + +const router = Router(); + +router.get("/events", EventsHandler); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/eventsWebhook.ts b/backend/src/routes/eventsWebhook.ts new file mode 100644 index 0000000..54322cf --- /dev/null +++ b/backend/src/routes/eventsWebhook.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import { EventsWebhookUpdate, EventsWebhookVerifier } from "../controllers/eventsWebhook"; + +const router = Router(); + +router.post("/eventsWebhook", EventsWebhookUpdate); +router.get("/eventsWebhook", EventsWebhookVerifier); + +export default router; \ No newline at end of file diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts new file mode 100644 index 0000000..6c2bf07 --- /dev/null +++ b/backend/src/types/express/index.d.ts @@ -0,0 +1,11 @@ +import "express"; + +declare module "express-serve-static-core" { + interface Request { + rawBody?: Buffer; + } + + interface IncomingMessage { + rawBody?: Buffer; + } +} diff --git a/backend/src/types/http/index.d.ts b/backend/src/types/http/index.d.ts new file mode 100644 index 0000000..36dce5a --- /dev/null +++ b/backend/src/types/http/index.d.ts @@ -0,0 +1,7 @@ +import "http"; + +declare module "http" { + interface IncomingMessage { + rawBody?: Buffer; + } +} diff --git a/backend/src/util.ts b/backend/src/util.ts new file mode 100644 index 0000000..9d4df0a --- /dev/null +++ b/backend/src/util.ts @@ -0,0 +1,52 @@ +// These are NOT thread-safe functions +export function filterInPlace( + arr: T[], + predicate: (value: T, index: number, array: T[]) => boolean +): T[] { + let write = 0; + for (let read = 0; read < arr.length; read++) { + const val = arr[read]; + if (predicate(val, read, arr)) { + arr[write++] = val; + } + } + arr.length = write; + return arr; +} + +export function replaceInPlace( + arr: T[], + predicate: (value: T, index: number, array: T[]) => boolean, + replacement: T +): number { + const idx = arr.findIndex(predicate); + if (idx !== -1) arr[idx] = replacement; + return idx; +} + +export interface FacebookError { + error: { + message: string; + type: string; + code: number; + error_subcode?: number; + fbtrace_id?: string; + } +} + +export enum ResultType { + Ok = "ok", + Err = "error", +} + +export interface Ok { + type: ResultType.Ok; + value: T; +} + +export interface Err { + type: ResultType.Err; + error: E; +} + +export type Result = Ok | Err; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 6bc9e20..f5a5419 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -31,7 +31,7 @@ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "typeRoots": ["./src/types"], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */