From 2669120524ec6d94afbdbcf470c1132656303b1e Mon Sep 17 00:00:00 2001 From: Ean Milligan Date: Thu, 9 Apr 2026 01:44:41 -0400 Subject: [PATCH] fix email length check, add unenroll api+email system --- .bruno/User Endpoints/auth user.bru | 2 +- .bruno/User Endpoints/unenroll user.bru | 24 ++++++++ config.example.ts | 7 +++ mod.ts | 81 ++++++++++++++++++++++++- 4 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 .bruno/User Endpoints/unenroll user.bru diff --git a/.bruno/User Endpoints/auth user.bru b/.bruno/User Endpoints/auth user.bru index f9ae072..373d94e 100644 --- a/.bruno/User Endpoints/auth user.bru +++ b/.bruno/User Endpoints/auth user.bru @@ -12,7 +12,7 @@ post { body:json { { - "name": "teest", + "name": "test", "pin": "1234" } } diff --git a/.bruno/User Endpoints/unenroll user.bru b/.bruno/User Endpoints/unenroll user.bru new file mode 100644 index 0000000..d7bfc94 --- /dev/null +++ b/.bruno/User Endpoints/unenroll user.bru @@ -0,0 +1,24 @@ +meta { + name: unenroll user + type: http + seq: 3 +} + +delete { + url: http://localhost:14014/api/unenroll + body: json + auth: inherit +} + +body:json { + { + "name": "test", + "pin": "1234", + "deleteCode": "" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/config.example.ts b/config.example.ts index f2d31bf..520d91c 100644 --- a/config.example.ts +++ b/config.example.ts @@ -11,6 +11,13 @@ export const config = { password: '', name: 'xivplan', }, + discordWebhook: '', + email: { + accountId: 'zoho-account-id, from https://mail.zoho.com/api/accounts', + address: 'zoho-email@example.com', + clientId: 'zoho-self-client-id', + clientSecret: 'zoho-self-client-secret', + }, }; export default config; diff --git a/mod.ts b/mod.ts index c6f544e..7c1e153 100644 --- a/mod.ts +++ b/mod.ts @@ -9,6 +9,13 @@ import dbClient from 'db/client.ts'; const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; const nanoid = customAlphabet(alphabet, 20); +const discordHeaders = new Headers(); +discordHeaders.append('Content-Type', 'application/json'); +discordHeaders.append('Accept', 'application/json'); + +const zohoHeaders = new Headers(discordHeaders); +let zohoAuthExpireDT = new Date().getTime(); + const genericResponse = (status: StatusCode, customText = '') => new Response(customText || STATUS_TEXT[status], { status: status, statusText: STATUS_TEXT[status] }); @@ -31,7 +38,7 @@ Deno.serve({ port: config.api.port }, async (req) => { if (userNameMatches.length === 0) { if (body.name.length < 4 || body.name.length > 20) return genericResponse(STATUS_CODE.BadRequest, `Name too ${body.name.length < 4 ? 'short' : 'long'}.`); if (body.pin.length < 4 || body.pin.length > 20) return genericResponse(STATUS_CODE.BadRequest, `PIN too ${body.pin.length < 4 ? 'short' : 'long'}.`); - if (body.email.length > 20) return genericResponse(STATUS_CODE.BadRequest, `Email too long.`); + if (body.email.length > 255) return genericResponse(STATUS_CODE.BadRequest, `Email too long.`); const id = nanoid(); @@ -59,19 +66,87 @@ Deno.serve({ port: config.api.port }, async (req) => { if (loginMatch.length === 0) return genericResponse(STATUS_CODE.Forbidden, 'Invalid name/PIN combination.'); const id = loginMatch[0].id; const email = loginMatch[0].email; + const hasEmail = email.length > 0; const deleteCode = loginMatch[0].deleteCode; switch (req.method) { case 'POST': if (path === '/auth' || path === '/auth/') { - return genericResponse(STATUS_CODE.OK, JSON.stringify({ id, hasEmail: email.length > 0 })); + return genericResponse(STATUS_CODE.OK, JSON.stringify({ id, hasEmail })); } break; case 'PUT': break; case 'DELETE': if (path === '/unenroll' || '/unenroll/') { - // + if (!hasEmail || body.deleteCode.trim() === deleteCode) { + let deleteFailure = false; + + await dbClient.execute('DELETE FROM plans WHERE ownerId = ?', [id]).catch(() => { + deleteFailure = true; + }); + if (deleteFailure) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't delete plans."); + + await dbClient.execute('DELETE FROM users WHERE id = ?', [id]).catch(() => { + deleteFailure = true; + }); + if (deleteFailure) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't delete user."); + + return genericResponse(STATUS_CODE.OK, 'Deleted user and plans.'); + } else if (hasEmail && !deleteCode) { + let updateFailure = false; + + const newDeleteCode = nanoid(); + await dbClient.execute('UPDATE users SET deleteCode = ? WHERE id = ?', [newDeleteCode, id]).catch(() => { + updateFailure = true; + }); + if (updateFailure) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't set deleteCode."); + + const nowDT = new Date().getTime(); + let fetchFailed = false; + if (zohoAuthExpireDT < nowDT) { + const getNewAuthToken = await fetch( + `https://accounts.zoho.com/oauth/v2/token?client_id=${config.email.clientId}&client_secret=${config.email.clientSecret}&grant_type=client_credentials&scope=ZohoMail.messages.CREATE`, + { method: 'POST' }, + ).catch(() => { + fetchFailed = true; + }); + if (fetchFailed || !getNewAuthToken) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't get auth token."); + + const newAuthToken = await getNewAuthToken.json(); + zohoHeaders.set('Authorization', `Zoho-oauthtoken ${newAuthToken.access_token}`); + zohoAuthExpireDT = nowDT + newAuthToken.expires_in * 1000 - 600000; + } + + const sendEmailReq = await fetch(`https://mail.zoho.com/api/accounts/${config.email.accountId}/messages`, { + method: 'POST', + headers: zohoHeaders, + body: JSON.stringify({ + fromAddress: config.email.address, + toAddress: email, + subject: 'XIVPlan+DB Delete Code', + content: `Notice: account deletion is permanent and will delete all plans saved under your account.

Please use the following Delete Code to delete your account:

${newDeleteCode}`, + }), + }).catch(() => { + fetchFailed = true; + }); + if (fetchFailed || !sendEmailReq) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't send email."); + + const sentEmail = await sendEmailReq.json(); + if (sentEmail.status.code !== 200) return genericResponse(STATUS_CODE.InternalServerError, 'Failed to send email.'); + + const maskedEmail = `${email.slice(0, 2)}***@***${email.slice(-5)}`; + fetch(config.discordWebhook, { + method: 'POST', + headers: discordHeaders, + body: JSON.stringify({ content: `Delete code email has been sent to ${maskedEmail}` }), + }).catch(() => {}); + return genericResponse(STATUS_CODE.PreconditionFailed, `Please resubmit with the confirmation code emailed to "${maskedEmail}".`); + } else if (hasEmail && body.deleteCode !== deleteCode) { + return genericResponse(STATUS_CODE.BadRequest, 'Invalid delete code.'); + } else { + return genericResponse(STATUS_CODE.InternalServerError, 'How are you here?'); + } } break; }