-
Notifications
You must be signed in to change notification settings - Fork 2
/
[imageFileName].ts
212 lines (197 loc) · 9.5 KB
/
[imageFileName].ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
// https://dev.to/sudo_overflow/diy-generating-dynamic-images-on-the-fly-for-email-marketing-h51
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import { createCanvas } from 'canvas';
import dayjs from 'dayjs';
import { getSimpleStringFromParam } from '../../../helpers/strings';
import { getNftContract, NFT } from '../mint-cert';
import { getNearAccountWithoutAccountIdOrKeyStoreForBackend } from '../../../helpers/near';
import { height, populateCert, width } from '../../../helpers/certificate-designs';
import prisma from '../../../helpers/prisma';
import { addCacheHeader } from '../../../helpers/caching';
import { formatDate } from '../../../helpers/time';
export const HTTP_ERROR_CODE_MISSING = 404;
const svg = 'svg';
const dot = '.';
const imagePng = 'image/png';
const expirationDays = 180;
const CACHE_SECONDS: number = Number(process.env.DYNAMIC_CERT_IMAGE_GENERATION_CACHE_SECONDS) || 60 * 60 * 6;
type CanvasTypeDef = 'pdf' | 'svg' | undefined;
type BufferTypeDef = 'image/png' | undefined;
type RawQueryResult = [
{
moment: string;
diff_to_previous_activity: number;
diff_from_last_activity_to_render_date: number;
has_long_period_of_inactivity: boolean;
},
];
function parseFileName(imageFileNameString: string) {
const extension = imageFileNameString.split(dot).pop(); // https://stackoverflow.com/a/1203361/470749
const contentType = extension === svg ? 'image/svg+xml' : imagePng;
const bufferType: BufferTypeDef = extension === svg ? undefined : imagePng;
const canvasType: CanvasTypeDef = extension === svg ? svg : undefined;
const lastIndex = imageFileNameString.lastIndexOf(`${dot}${extension}`); // https://stackoverflow.com/a/9323226/470749
const tokenId = imageFileNameString.substring(0, lastIndex);
return { extension, bufferType, contentType, canvasType, tokenId };
}
async function generateImage(canvasType: CanvasTypeDef, bufferType: BufferTypeDef, details: any) {
const canvas = createCanvas(width, height, canvasType);
await populateCert(canvas, details);
// Convert the Canvas to a buffer
const buffer = bufferType ? canvas.toBuffer(bufferType) : canvas.toBuffer();
return buffer;
}
// eslint-disable-next-line max-lines-per-function
async function getExpiration(accountName: string, issuedAt: string): Promise<unknown> {
// Pulls from the public indexer. https://github.com/near/near-indexer-for-explorer#shared-public-access
/**
* This query uses Common Table Expressions(CTE) to execute two separate queries conditionally;
* the second query being executed if first query doesn't return any result.
* https://www.postgresql.org/docs/9.1/queries-with.html
* https://stackoverflow.com/a/68684814/10684149
*/
/**
* First query checks If the account has a period where it hasn't been active for 180 days straight after the issue date (exluding the render date)
* Second query is run if no 180-day-inactivity period is found and returns most recent activity date
* AND amount of days between account's last activity date - render date of certificate
*/
const issuedAtUnixNano = dayjs(issuedAt).unix() * 1_000_000_000;
console.log({ issuedAt, issuedAtUnixNano, accountName });
// https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#queryraw
const result: RawQueryResult = await prisma.$queryRaw<RawQueryResult>`
WITH long_period_of_inactivity AS (
SELECT
moment,
diff_to_previous_activity,
CAST(NULL AS int) AS diff_from_last_activity_to_render_date, /* to match column numbers in both queries */
true AS has_long_period_of_inactivity
FROM (
SELECT *,
((EXTRACT(epoch FROM moment) - EXTRACT(epoch FROM lag(moment) over (ORDER BY moment))) / 86400)::int /* 1 day = 60sec * 60min * 24h = 86400 sec*/
AS diff_to_previous_activity
FROM (
SELECT TO_TIMESTAMP(R."included_in_block_timestamp"/1000000000) as moment
FROM PUBLIC.RECEIPTS R
LEFT OUTER JOIN PUBLIC.ACTION_RECEIPTS AR ON R.RECEIPT_ID = AR.RECEIPT_ID
WHERE SIGNER_ACCOUNT_ID = ${accountName}
AND R."included_in_block_timestamp" > ${issuedAtUnixNano}
) as account_activity_dates
) as account_activity_periods
WHERE (diff_to_previous_activity > ${expirationDays})
ORDER BY moment ASC
LIMIT 1
), most_recent_activity AS (
SELECT
moment,
CAST(NULL AS int) AS diff_to_previous_activity, /* to match column numbers in both queries */
((EXTRACT(epoch FROM CURRENT_TIMESTAMP) - EXTRACT(epoch FROM moment)) / 86400)::int
AS diff_from_last_activity_to_render_date,
false AS has_long_period_of_inactivity
FROM (
SELECT TO_TIMESTAMP(R."included_in_block_timestamp"/1000000000) as moment
FROM PUBLIC.receipts R
LEFT OUTER JOIN PUBLIC.ACTION_RECEIPTS AR ON R.RECEIPT_ID = AR.RECEIPT_ID
WHERE SIGNER_ACCOUNT_ID = ${accountName}
AND R."included_in_block_timestamp" > ${issuedAtUnixNano}
) as receipt
WHERE NOT EXISTS (TABLE long_period_of_inactivity)
ORDER BY moment DESC
LIMIT 1
)
TABLE long_period_of_inactivity
UNION ALL
TABLE most_recent_activity`;
console.log('getExpiration query result', { result });
/**
* If the account doesn't have a period where it hasn't been active for 180 days straight after the issue date:
* Days between last activity and render date is checked:
* If this value (diff_from_last_activity_to_render_date) is >180;
* -- Certificate is expired. Expiration date = last activity + 180 days
* If this value (diff_from_last_activity_to_render_date) is <180;
* -- Certificate hasn't expired yet. Expiration date = last activity + 180 days
* Otherwise, if >180-day period of inactivity exist (has_long_period_of_inactivity === true) after issueDate,
* -- Expiration date = the beginning of the *first* such period + 180 days.
*/
const moment = dayjs(result[0].moment);
let expirationDate;
let isExpired;
if (result[0].has_long_period_of_inactivity) {
/**
* >180-day period of inactivity exists. Can be anything over 180.
* moment is the end date of such period.
* Extract 180 from inactivity period to get the exact days betwen moment and actual expiration date.
*/
const daysToMomentOfExpiration = result[0].diff_to_previous_activity - expirationDays;
/**
* Subtract daysToMomentOfExpiration from moment to get the specific date of expiration.
* This subtraction equals to (start of inactivity period + 180 days)
*/
expirationDate = formatDate(moment.subtract(daysToMomentOfExpiration, 'days'));
isExpired = true;
} else {
expirationDate = formatDate(moment.add(expirationDays, 'days'));
isExpired = result[0].diff_from_last_activity_to_render_date > expirationDays;
}
return {
expirationDate,
isExpired,
};
}
// eslint-disable-next-line max-lines-per-function
async function fetchCertificateDetails(tokenId: string) {
const account = await getNearAccountWithoutAccountIdOrKeyStoreForBackend();
const contract = getNftContract(account);
const response = await (contract as NFT).nft_token({ token_id: tokenId });
if (response) {
const { metadata } = response;
const { extra } = metadata;
const certificateMetadata = JSON.parse(extra);
console.log({ contract, response, certificateMetadata });
// similar to isValid function but without re-running some of those lines
if (certificateMetadata.valid) {
const accountName = certificateMetadata.original_recipient_id;
const programCode = certificateMetadata.program;
let expiration = null; // The UI (see `generateImage`) will need to gracefully handle this case when indexer service is unavailable.
try {
expiration = await getExpiration(accountName, metadata.issued_at);
// E.g.: expiration: { expirationDate: '2022-08-16', isExpired: false }
} catch (error) {
console.error('Perhaps a certificate for the original_recipient_id could not be found or the public indexer query timed out.', error);
}
const date = formatDate(metadata.issued_at);
const programName = metadata.title;
const programDescription = metadata.description;
const instructor = certificateMetadata.authority_id;
return {
tokenId,
date,
programCode, // This will determine which background image gets used.
programName,
accountName,
expiration,
programDescription,
instructor,
};
}
}
return null;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse<Buffer | { error: string }>) {
// Grab payload from query.
const { imageFileName } = req.query;
const imageFileNameString = getSimpleStringFromParam(imageFileName);
const { bufferType, contentType, canvasType, tokenId } = parseFileName(imageFileNameString);
console.log({ bufferType, contentType, canvasType, tokenId });
const details = await fetchCertificateDetails(tokenId);
if (details) {
// Provide each piece of text to generateImage.
const imageBuffer = await generateImage(canvasType, bufferType, details);
res.setHeader('Content-Type', contentType);
addCacheHeader(res, CACHE_SECONDS);
// Caching is important especially because of getMostRecentActivityDateTime, which pulls from the public indexer database.
res.send(imageBuffer);
} else {
res.status(HTTP_ERROR_CODE_MISSING).json({ error: `No certificate exists with this Token ID (${tokenId}).` });
}
}