@@ -17,7 +17,7 @@ const IV_LENGTH = 16;
17
17
const SALT_LENGTH = 16 ;
18
18
const TAG_LENGTH = 16 ;
19
19
const KEY_LENGTH = 32 ; // AES-256
20
- const PBKDF2_ITERATIONS = 100000 ;
20
+ const PBKDF2_ITERATIONS = 310000 ;
21
21
22
22
/**
23
23
* Decrypts text encrypted with AES-256-GCM.
@@ -48,16 +48,34 @@ function decryptAES256GCM(encryptedText, passwordKey) {
48
48
// Setup PayPal environment
49
49
const clientId = process . env . PAYPAL_CLIENT_ID ;
50
50
const clientSecret = process . env . PAYPAL_CLIENT_SECRET ;
51
+ const webhookId = process . env . PAYPAL_WEBHOOK_ID ;
51
52
52
- if ( ! clientId || ! clientSecret ) {
53
- console . error ( "CRITICAL ERROR: PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables are not defined." ) ;
54
- console . error ( "These variables are required for PayPal integration." ) ;
53
+ if ( ! clientId || ! clientSecret || ! webhookId ) {
54
+ console . error ( "CRITICAL ERROR: PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, and PAYPAL_WEBHOOK_ID environment variables are not defined." ) ;
55
+ console . error ( "These variables are required for PayPal integration and webhook verification ." ) ;
55
56
console . error ( "Without them, payment functionalities will not operate correctly." ) ;
56
57
}
57
58
58
59
const environment = new paypal . core . SandboxEnvironment ( clientId , clientSecret ) ;
59
60
const client = new paypal . core . PayPalHttpClient ( environment ) ;
60
61
62
+ // Middleware to capture raw body
63
+ app . use ( ( req , res , next ) => {
64
+ if ( req . originalUrl === '/paypal/webhook' ) { // Only for PayPal webhook route
65
+ let data = '' ;
66
+ req . setEncoding ( 'utf8' ) ;
67
+ req . on ( 'data' , chunk => {
68
+ data += chunk ;
69
+ } ) ;
70
+ req . on ( 'end' , ( ) => {
71
+ req . rawBody = data ;
72
+ next ( ) ;
73
+ } ) ;
74
+ } else {
75
+ next ( ) ;
76
+ }
77
+ } ) ;
78
+
61
79
app . use ( express . json ( ) ) ;
62
80
app . use ( express . static ( 'public' ) ) ;
63
81
@@ -84,7 +102,8 @@ const db = new sqlite3.Database(DB_PATH, (err) => {
84
102
paypal_order_id TEXT UNIQUE,
85
103
status TEXT NOT NULL,
86
104
cli_session_id TEXT UNIQUE,
87
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
105
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
106
+ token_used_at DATETIME DEFAULT NULL
88
107
)` , ( err ) => {
89
108
if ( err ) {
90
109
console . error ( "Error creating 'purchases' table:" , err . message ) ;
@@ -159,27 +178,93 @@ app.get('/pay/:patchId', async (req, res) => {
159
178
} ) ;
160
179
161
180
app . post ( '/paypal/webhook' , async ( req , res ) => {
162
- const webhookEvent = req . body ;
163
- if ( webhookEvent . event_type === 'CHECKOUT.ORDER.APPROVED' || webhookEvent . event_type === 'CHECKOUT.ORDER.COMPLETED' ) {
164
- const orderID = webhookEvent . resource . id ;
165
- const purchaseToken = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
166
- db . run ( `UPDATE purchases SET status = 'COMPLETED', purchase_token = ? WHERE paypal_order_id = ?` ,
167
- [ purchaseToken , orderID ] ,
168
- function ( err ) {
181
+ const transmissionId = req . headers [ 'paypal-transmission-id' ] ;
182
+ const transmissionTime = req . headers [ 'paypal-transmission-time' ] ;
183
+ const transmissionSig = req . headers [ 'paypal-transmission-sig' ] ;
184
+ const certUrl = req . headers [ 'paypal-cert-url' ] ;
185
+ const authAlgo = req . headers [ 'paypal-auth-algo' ] ;
186
+ const requestBody = req . rawBody || JSON . stringify ( req . body ) ; // PayPal SDK requires the raw body
187
+
188
+ try {
189
+ // Note: Accessing webhooks directly from the paypal object as per documentation
190
+ const { verified, event : verifiedEvent } = await paypal . webhooks . Webhooks . verifyAndGetWebhookEvent (
191
+ transmissionId ,
192
+ transmissionTime ,
193
+ transmissionSig ,
194
+ certUrl ,
195
+ webhookId , // PAYPAL_WEBHOOK_ID from env
196
+ requestBody ,
197
+ authAlgo
198
+ ) ;
199
+
200
+ if ( ! verified ) {
201
+ console . error ( "Webhook verification failed." ) ;
202
+ return res . status ( 403 ) . send ( "Webhook verification failed." ) ;
203
+ }
204
+
205
+ if ( verifiedEvent . event_type === 'CHECKOUT.ORDER.APPROVED' ) {
206
+ const orderID = verifiedEvent . resource . id ;
207
+ // Check current status before updating
208
+ db . get ( `SELECT status FROM purchases WHERE paypal_order_id = ?` , [ orderID ] , ( err , row ) => {
169
209
if ( err ) {
170
- console . error ( `Error updating purchase status for PayPal order ${ orderID } :` , err . message ) ;
210
+ console . error ( `Error fetching purchase status for PayPal order ${ orderID } :` , err . message ) ;
171
211
return res . sendStatus ( 500 ) ;
172
212
}
173
- if ( this . changes === 0 ) {
174
- console . warn ( `No purchase found or updated for PayPal order ${ orderID } . The order may not exist or may have already been processed .`) ;
175
- return res . sendStatus ( 404 ) ;
213
+ if ( row && row . status === 'COMPLETED' ) {
214
+ console . log ( `Order ${ orderID } is already marked as COMPLETED. No action taken .`) ;
215
+ return res . sendStatus ( 200 ) ; // Or 204 No Content
176
216
}
177
- console . log ( `Purchase completed and token generated for PayPal order ${ orderID } . Token: ${ purchaseToken } ` ) ;
178
- res . sendStatus ( 200 ) ;
217
+
218
+ const purchaseToken = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
219
+ db . run ( `UPDATE purchases SET status = 'COMPLETED', purchase_token = ? WHERE paypal_order_id = ? AND status != 'COMPLETED'` ,
220
+ [ purchaseToken , orderID ] ,
221
+ function ( updateErr ) {
222
+ if ( updateErr ) {
223
+ console . error ( `Error updating purchase status for PayPal order ${ orderID } :` , updateErr . message ) ;
224
+ return res . sendStatus ( 500 ) ;
225
+ }
226
+ if ( this . changes === 0 ) {
227
+ console . warn ( `No purchase found or updated for PayPal order ${ orderID } . The order may not exist or was already processed.` ) ;
228
+ // It's possible it was already completed, so sending 200 is okay if the goal is idempotency.
229
+ // If it must be found and not completed, then 404 might be more appropriate.
230
+ // Given the pre-check, this path might indicate a race condition or an issue.
231
+ return res . sendStatus ( 200 ) ; // Adjusted from 404 as status check is now above
232
+ }
233
+ console . log ( `Purchase COMPLETED (from APPROVED) and token generated for PayPal order ${ orderID } . Token: ${ purchaseToken } ` ) ;
234
+ res . sendStatus ( 200 ) ;
235
+ } ) ;
179
236
} ) ;
180
- } else {
181
- console . log ( `Unhandled webhook event received: ${ webhookEvent . event_type } ` ) ;
182
- res . sendStatus ( 200 ) ;
237
+ } else if ( verifiedEvent . event_type === 'CHECKOUT.ORDER.COMPLETED' ) {
238
+ const orderID = verifiedEvent . resource . id ;
239
+ const purchaseToken = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
240
+ // This path assumes CHECKOUT.ORDER.COMPLETED means we should finalize,
241
+ // potentially overwriting if it was APPROVED but not yet tokenized by that path.
242
+ // Or, if it's a direct COMPLETED event.
243
+ db . run ( `UPDATE purchases SET status = 'COMPLETED', purchase_token = ? WHERE paypal_order_id = ?` ,
244
+ [ purchaseToken , orderID ] , // Consider adding AND status != 'COMPLETED' if needed, though COMPLETED event should be final.
245
+ function ( err ) {
246
+ if ( err ) {
247
+ console . error ( `Error updating purchase status for PayPal order ${ orderID } (on COMPLETED event):` , err . message ) ;
248
+ return res . sendStatus ( 500 ) ;
249
+ }
250
+ if ( this . changes === 0 ) {
251
+ // This could happen if the order was already processed by an APPROVED event or doesn't exist
252
+ console . warn ( `No purchase found or updated for PayPal order ${ orderID } on COMPLETED event. May have been processed or not exist.` ) ;
253
+ return res . sendStatus ( 200 ) ; // Or 404 if it must exist
254
+ }
255
+ console . log ( `Purchase COMPLETED (from COMPLETED event) and token generated for PayPal order ${ orderID } . Token: ${ purchaseToken } ` ) ;
256
+ res . sendStatus ( 200 ) ;
257
+ } ) ;
258
+ } else {
259
+ console . log ( `Unhandled webhook event received: ${ verifiedEvent . event_type } ` ) ;
260
+ res . sendStatus ( 200 ) ;
261
+ }
262
+ } catch ( err ) {
263
+ console . error ( "Error processing PayPal webhook:" , err . message , err . stack ) ;
264
+ if ( err . name === 'WEBHOOK_SIGNATURE_VERIFICATION_FAILED' ) {
265
+ return res . status ( 403 ) . send ( 'Webhook signature verification failed.' ) ;
266
+ }
267
+ res . status ( 500 ) . send ( 'Error processing webhook' ) ;
183
268
}
184
269
} ) ;
185
270
@@ -230,7 +315,7 @@ app.post('/get-patch', async (req, res) => {
230
315
return res . status ( 500 ) . json ( { error : "Server configuration error." } ) ;
231
316
}
232
317
233
- db . get ( `SELECT * FROM purchases WHERE patch_id = ? AND purchase_token = ? AND status = 'COMPLETED'` ,
318
+ db . get ( `SELECT id, patch_id, purchase_token, status, token_used_at FROM purchases WHERE patch_id = ? AND purchase_token = ? AND status = 'COMPLETED'` ,
234
319
[ patchId , purchaseToken ] ,
235
320
async ( err , row ) => {
236
321
if ( err ) {
@@ -240,13 +325,33 @@ app.post('/get-patch', async (req, res) => {
240
325
if ( ! row ) {
241
326
return res . status ( 401 ) . json ( { error : "Invalid or unauthorized purchase token." } ) ;
242
327
}
328
+ if ( row . token_used_at ) {
329
+ console . warn ( `Attempt to reuse token for purchase ID ${ row . id } (Patch ${ patchId } ). Token already used at ${ row . token_used_at } .` ) ;
330
+ return res . status ( 403 ) . json ( { error : "Purchase token has already been used." } ) ;
331
+ }
243
332
244
333
const encryptedFilePath = path . join ( __dirname , 'patches' , `${ patchId } .taylored.enc` ) ;
245
334
try {
246
335
const encryptedContent = await fs . readFile ( encryptedFilePath , 'utf-8' ) ;
247
336
const decryptedContent = decryptAES256GCM ( encryptedContent , encryptionKey ) ;
248
- res . setHeader ( 'Content-Type' , 'text/plain' ) ;
249
- res . status ( 200 ) . send ( decryptedContent ) ;
337
+
338
+ // Mark token as used before sending content
339
+ db . run ( `UPDATE purchases SET token_used_at = CURRENT_TIMESTAMP WHERE id = ?` , [ row . id ] , function ( updateErr ) {
340
+ if ( updateErr ) {
341
+ console . error ( `Failed to mark token as used for purchase ID ${ row . id } :` , updateErr . message ) ;
342
+ // Decide if we should still send the content or return an error.
343
+ // For now, let's assume if we can't mark it, we shouldn't send it to be safe.
344
+ return res . status ( 500 ) . json ( { error : "Server error processing request." } ) ;
345
+ }
346
+ if ( this . changes === 0 ) {
347
+ // This case should ideally not be reached if the initial SELECT worked.
348
+ console . error ( `Failed to update token_used_at: No rows updated for purchase ID ${ row . id } .` ) ;
349
+ return res . status ( 500 ) . json ( { error : "Failed to finalize purchase." } ) ;
350
+ }
351
+ console . log ( `Token for purchase ID ${ row . id } (Patch ${ patchId } ) marked as used.` ) ;
352
+ res . setHeader ( 'Content-Type' , 'text/plain' ) ;
353
+ res . status ( 200 ) . send ( decryptedContent ) ;
354
+ } ) ;
250
355
} catch ( error ) {
251
356
if ( error . code === 'ENOENT' ) {
252
357
console . error ( `Encrypted patch file not found at: ${ encryptedFilePath } ` , error ) ;
@@ -266,8 +371,8 @@ app.get('/', (req, res) => {
266
371
app . listen ( PORT , ( ) => {
267
372
console . log ( `Server listening on port ${ PORT } ` ) ;
268
373
console . log ( `Server base URL: ${ SERVER_BASE_URL } ` ) ;
269
- if ( ! process . env . PAYPAL_CLIENT_ID || ! process . env . PAYPAL_CLIENT_SECRET ) {
270
- console . warn ( "WARNING: PAYPAL_CLIENT_ID or PAYPAL_CLIENT_SECRET are not set. PayPal functionality will be unavailable." ) ;
374
+ if ( ! process . env . PAYPAL_CLIENT_ID || ! process . env . PAYPAL_CLIENT_SECRET || ! process . env . PAYPAL_WEBHOOK_ID ) {
375
+ console . warn ( "WARNING: PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET or PAYPAL_WEBHOOK_ID are not set. PayPal functionality will be unavailable or insecure ." ) ;
271
376
}
272
377
} ) ;
273
378
0 commit comments