Skip to content

Commit dc642bf

Browse files
committed
feat(book): webhook for compilation updates from Shapeshift
1 parent 4a1ac38 commit dc642bf

7 files changed

Lines changed: 119 additions & 4 deletions

File tree

client/src/types/Book.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export type Book = {
1616
thumbnail: string;
1717
thumbnailIsAnimated?: boolean;
1818
summary: string;
19+
isCompiled?: boolean;
20+
lastCompiled?: number;
1921
rating: number;
2022
links: BookLinks;
2123
lastUpdated: string;

server/api.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3219,6 +3219,12 @@ router.route('/shapeshift/job').post(
32193219
catchInternal((req, res) => shapeshiftAPI.createJob(req, res)),
32203220
);
32213221

3222+
router.route("/shapeshift/webhook").post(
3223+
middleware.checkShapeshiftWebhookKey,
3224+
middleware.validateZod(ShapeshiftValidators.WebhookValidator),
3225+
catchInternal((req, res) => shapeshiftAPI.handleWebhook(req, res))
3226+
);
3227+
32223228
router.route('/book-bots/editor-preprocess').post(
32233229
authAPI.verifyRequest,
32243230
authAPI.getUserAttributes,

server/api/services/shapeshift-service.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import {ShapeshiftJob, ShapeshiftJobStatus} from "../../types/Shapeshift";
2-
import axios, {AxiosInstance} from "axios";
3-
import {debugError} from "../../debug";
1+
import { ShapeshiftJob, ShapeshiftJobStatus } from "../../types/Shapeshift";
2+
import axios, { AxiosInstance } from "axios";
3+
import { debugError } from "../../debug";
4+
import Book from "../../models/book";
45

56
export default class ShapeshiftService {
67
private instance: AxiosInstance;
@@ -65,4 +66,38 @@ export default class ShapeshiftService {
6566
return emptyResponse;
6667
}
6768
}
69+
70+
/**
71+
* Handle a webhook from Shapeshift to update the book's compilation status.
72+
* @param bookID - The ID of the book to update.
73+
* @param timestamp - The timestamp of the webhook event.
74+
* @returns - A string indicating the result of the operation: 'success', 'not_found', 'invalid_timestamp', or 'error'.
75+
*/
76+
public async handleWebhook(bookID: string, timestamp: number): Promise<'success' | 'not_found' | 'invalid_timestamp' | 'error'> {
77+
try {
78+
const acceptedSkew = 5 * 60 * 1000; // 5 minutes in milliseconds
79+
const currentTime = Date.now();
80+
81+
// Accept the webhook if the timestamp is plus or minus 5 minutes from the current time
82+
if (Math.abs(currentTime - timestamp) > acceptedSkew) {
83+
debugError(`Timestamp for Shapeshift webhook is too skewed. Received: ${timestamp}, Current: ${currentTime}`);
84+
return 'invalid_timestamp';
85+
}
86+
87+
const book = await Book.updateOne({ bookID: { $eq: bookID } }, {
88+
$set: { isCompiled: true },
89+
$max: { lastCompiled: timestamp }, // Only update lastCompiled if the new timestamp is greater than the existing value
90+
});
91+
92+
if (!book || book.matchedCount === 0) {
93+
debugError(`Book with bookID ${bookID} not found for Shapeshift webhook.`);
94+
return 'not_found';
95+
}
96+
97+
return 'success';
98+
} catch (error) {
99+
debugError(error);
100+
return 'error';
101+
}
102+
}
68103
}

server/api/shapeshift.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Response } from "express";
22
import { z } from "zod";
33
import { ZodReqWithUser } from "../types";
4-
import { CreateJobValidator, GetJobsValidator } from "./validators/shapeshift";
4+
import { CreateJobValidator, GetJobsValidator, WebhookValidator } from "./validators/shapeshift";
55
import ShapeshiftService from "./services/shapeshift-service";
66

77
export async function createJob(
@@ -40,3 +40,38 @@ export async function getJobs(
4040
jobs,
4141
});
4242
}
43+
44+
export async function handleWebhook(
45+
req: z.infer<typeof WebhookValidator>,
46+
res: Response
47+
) {
48+
const service = new ShapeshiftService();
49+
const { bookID, timestamp } = req.body;
50+
const result = await service.handleWebhook(bookID, timestamp);
51+
52+
if (result === 'not_found') {
53+
return res.status(404).json({
54+
err: true,
55+
msg: 'Book not found for webhook.',
56+
});
57+
}
58+
59+
if (result === 'invalid_timestamp') {
60+
return res.status(400).json({
61+
err: true,
62+
msg: 'Invalid timestamp for webhook.',
63+
});
64+
}
65+
66+
if (result === 'error') {
67+
return res.status(500).json({
68+
err: true,
69+
msg: 'Error processing webhook.',
70+
});
71+
}
72+
73+
return res.status(200).json({
74+
err: false,
75+
msg: 'Webhook processed.',
76+
});
77+
}

server/api/validators/shapeshift.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from "zod";
2+
import { bookIDSchema } from "./book.js";
23

34
export const CreateJobValidator = z.object({
45
body: z.object({
@@ -20,3 +21,11 @@ export const GetJobsValidator = z.object({
2021
.optional(),
2122
}),
2223
});
24+
25+
26+
export const WebhookValidator = z.object({
27+
body: z.object({
28+
bookID: bookIDSchema,
29+
timestamp: z.number().int().nonnegative(), // Unix timestamp in seconds
30+
}),
31+
});

server/middleware.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,23 @@ const checkEventBridgeAPIKey = (req: Request, res: Response, next: NextFunction)
104104
return res.status(401).send({ errMsg: conductorErrors.err5 });
105105
};
106106

107+
/**
108+
* Verifies that a request has provided a valid key from a Shapeshift webhook.
109+
* @param {object} req - The Express.js request object.
110+
* @param {object} res - The Express.js response object.
111+
* @param {function} next - The next function in the middleware chain.
112+
*/
113+
const checkShapeshiftWebhookKey = (req: Request, res: Response, next: NextFunction) => {
114+
if (typeof req.headers?.authorization === "string") {
115+
const foundToken = req.headers.authorization.replace("Bearer ", "");
116+
if (!process.env.SHAPESHIFT_WEBHOOK_KEY || process.env.SHAPESHIFT_WEBHOOK_KEY.length === 0) {
117+
return res.status(500).send({ errMsg: conductorErrors.err6 });
118+
}
119+
if (process.env.SHAPESHIFT_WEBHOOK_KEY === foundToken) return next();
120+
}
121+
return res.status(401).send({ errMsg: conductorErrors.err5 });
122+
};
123+
107124
/**
108125
* Reconstructs the Authorization header from cookies, if not already present.
109126
*
@@ -481,6 +498,7 @@ export default {
481498
checkLibreCommons,
482499
checkLibreAPIKey,
483500
checkEventBridgeAPIKey,
501+
checkShapeshiftWebhookKey,
484502
authSanitizer,
485503
middlewareFilter,
486504
checkCentralIdentityConfig,

server/models/book.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export interface BookInterface extends Document {
1414
license?: string;
1515
thumbnail?: string;
1616
summary?: string;
17+
isCompiled?: boolean;
18+
lastCompiled?: number;
1719
rating?: number;
1820
links?: {
1921
online?: string;
@@ -99,6 +101,14 @@ const BookSchema = new Schema<BookInterface>(
99101
* The Book's overview/description/summary.
100102
*/
101103
summary: String,
104+
/**
105+
* Whether the Book has, at any point, been successfully compiled by Shapeshift.
106+
*/
107+
isCompiled: Boolean,
108+
/**
109+
* Timestamp of the most recent successful compilation by Shapeshift (Unix timestamp in seconds).
110+
*/
111+
lastCompiled: Number,
102112
/**
103113
* The overall quality, on a scale of 0-5. Value is the average of all Peer Review
104114
* overall ratings submitted on the Book.

0 commit comments

Comments
 (0)