285 lines
15 KiB
TypeScript
285 lines
15 KiB
TypeScript
import { customAlphabet } from '@nanoid';
|
|
import { STATUS_CODE, STATUS_TEXT, StatusCode } from '@std/http';
|
|
|
|
import config from '~config';
|
|
|
|
import dbClient from 'db/client.ts';
|
|
|
|
// Using custom alphabet to exclude - and _
|
|
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 = '', customHeaders = new Headers()) =>
|
|
new Response(customText || STATUS_TEXT[status], { status: status, statusText: STATUS_TEXT[status], headers: customHeaders });
|
|
|
|
Deno.serve({ port: config.api.port }, async (req) => {
|
|
try {
|
|
const urlPath = req.url.split('?')[0] ?? '';
|
|
let rawPath = (urlPath.split('api')[1] ?? '').trim();
|
|
if (rawPath.endsWith('/')) rawPath = rawPath.slice(0, -1);
|
|
const path = rawPath;
|
|
console.log(urlPath, path);
|
|
|
|
let failed = false;
|
|
if (req.method === 'GET') {
|
|
if (path === '/home') {
|
|
// SSR "login page"
|
|
return genericResponse(STATUS_CODE.Unauthorized, 'Please sign in.');
|
|
} else if (path.startsWith('/home/')) {
|
|
// SSR "home page"
|
|
return genericResponse(STATUS_CODE.NotImplemented, 'WIP');
|
|
} else if (path.startsWith('/read/')) {
|
|
const planId = path.replace('/read/', '');
|
|
|
|
const plans = await dbClient.query('SELECT name, folder, data FROM plans WHERE id = ?', [planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
if (!plans.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
|
|
|
|
return genericResponse(STATUS_CODE.OK, JSON.stringify(plans[0]));
|
|
} else if (path.startsWith('/list/')) {
|
|
const userId = path.replace('/list/', '');
|
|
const userMatch = await dbClient.query('SELECT id FROM users WHERE id = ?', [userId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
if (!userMatch.length) return genericResponse(STATUS_CODE.NotFound, 'User ID does not exist.');
|
|
|
|
const plans = await dbClient.query('SELECT id, name, folder FROM plans WHERE ownerId = ?', [userId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
|
|
return genericResponse(STATUS_CODE.OK, JSON.stringify(plans));
|
|
} else if (path.startsWith('/export/')) {
|
|
const userId = path.replace('/list/', '');
|
|
const userMatch = await dbClient.query('SELECT id FROM users WHERE id = ?', [userId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
if (!userMatch.length) return genericResponse(STATUS_CODE.NotFound, 'User ID does not exist.');
|
|
|
|
// WIP: export plans to zip code goes here
|
|
return genericResponse(STATUS_CODE.NotImplemented, 'WIP');
|
|
}
|
|
} else if (req.method === 'POST' && path === '/enroll') {
|
|
const body = await req.json();
|
|
|
|
const userNameMatches = await dbClient.query('SELECT name FROM users WHERE name = ?', [body.name]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
|
|
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 > 255) return genericResponse(STATUS_CODE.BadRequest, 'Email too long.');
|
|
|
|
const id = nanoid();
|
|
|
|
await dbClient.execute('INSERT INTO users(id,name,pin,email) values(?,?,?,?)', [id, body.name, body.pin, body.email]).catch(() => {
|
|
failed = true;
|
|
});
|
|
|
|
if (failed) {
|
|
return genericResponse(STATUS_CODE.InternalServerError, "Couldn't write DB.");
|
|
} else {
|
|
return genericResponse(STATUS_CODE.OK, JSON.stringify({ id }));
|
|
}
|
|
} else {
|
|
return genericResponse(STATUS_CODE.BadRequest, 'Username Taken.');
|
|
}
|
|
} else {
|
|
const body = await req.json();
|
|
|
|
const loginMatch = await dbClient.query('SELECT id, email, deleteCode FROM users WHERE name = ? AND pin = ?', [body.name, body.pin]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
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') {
|
|
return genericResponse(STATUS_CODE.OK, JSON.stringify({ id, hasEmail }));
|
|
} else if (path === '/create') {
|
|
if (body.planName.trim().length > 200) return genericResponse(STATUS_CODE.BadRequest, 'Name too long.');
|
|
if (body.folder.trim() && body.folder.trim().length > 200) return genericResponse(STATUS_CODE.BadRequest, 'Folder name too long.');
|
|
|
|
const newPlanId = nanoid();
|
|
await dbClient
|
|
.execute('INSERT INTO plans(id,ownerId,name,folder,data) values(?,?,?,?,?)', [newPlanId, id, body.planName.trim(), body.folder.trim(), body.data])
|
|
.catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't write DB.");
|
|
return genericResponse(STATUS_CODE.OK, JSON.stringify({ id: newPlanId, name: body.planName.trim() }));
|
|
}
|
|
break;
|
|
case 'PUT':
|
|
if (path.startsWith('/undelete/')) {
|
|
const planId = path.replace('/undelete/', '');
|
|
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE id = ?', [planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
if (!planMatch.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
|
|
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
|
|
|
|
await dbClient.execute('UPDATE plans SET deleted = 0 WHERE id = ?', [planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");
|
|
return genericResponse(STATUS_CODE.OK, 'Plan restored.');
|
|
} else if (path.startsWith('/update/')) {
|
|
const planId = path.replace('/update/', '');
|
|
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE id = ?', [planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
if (!planMatch.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
|
|
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
|
|
|
|
await dbClient.execute('UPDATE plans SET data = ? WHERE id = ?', [body.data, planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");
|
|
return genericResponse(STATUS_CODE.OK, 'Plan updated.');
|
|
} else if (path.startsWith('/rename/')) {
|
|
if (body.planName.trim().length > 200) return genericResponse(STATUS_CODE.BadRequest, 'Name too long.');
|
|
|
|
const planId = path.replace('/rename/', '');
|
|
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE id = ?', [planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
if (!planMatch.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
|
|
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
|
|
|
|
await dbClient.execute('UPDATE plans SET name = ? WHERE id = ?', [body.planName, planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");
|
|
return genericResponse(STATUS_CODE.OK, 'Plan renamed.');
|
|
} else if (path.startsWith('/move/')) {
|
|
if (body.folder.trim().length > 200) return genericResponse(STATUS_CODE.BadRequest, 'Folder name too long.');
|
|
|
|
const planId = path.replace('/move/', '');
|
|
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE id = ?', [planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
if (!planMatch.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
|
|
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
|
|
|
|
await dbClient.execute('UPDATE plans SET folder = ? WHERE id = ?', [body.folder, planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");
|
|
return genericResponse(STATUS_CODE.OK, 'Plan moved.');
|
|
}
|
|
break;
|
|
case 'DELETE':
|
|
if (path === '/unenroll') {
|
|
if (!hasEmail || body.deleteCode.trim() === deleteCode) {
|
|
await dbClient.execute('DELETE FROM plans WHERE ownerId = ?', [id]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't delete plans.");
|
|
|
|
await dbClient.execute('DELETE FROM users WHERE id = ?', [id]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't delete user.");
|
|
|
|
return genericResponse(STATUS_CODE.OK, 'Deleted user and plans.');
|
|
} else if (hasEmail && !deleteCode) {
|
|
const newDeleteCode = nanoid();
|
|
await dbClient.execute('UPDATE users SET deleteCode = ? WHERE id = ?', [newDeleteCode, id]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't set deleteCode.");
|
|
|
|
const nowDT = new Date().getTime();
|
|
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(() => {
|
|
failed = true;
|
|
});
|
|
if (failed || !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.<br/><br/>Please use the following Delete Code to delete your account:<br/><br/>${newDeleteCode}`,
|
|
}),
|
|
}).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed || !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?');
|
|
}
|
|
} else if (path.startsWith('/delete/')) {
|
|
const planId = path.replace('/delete/', '');
|
|
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE id = ?', [planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
|
|
if (!planMatch.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
|
|
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
|
|
|
|
await dbClient.execute('UPDATE plans SET deleted = 1 WHERE id = ?', [planId]).catch(() => {
|
|
failed = true;
|
|
});
|
|
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");
|
|
return genericResponse(STATUS_CODE.OK, 'Plan deleted.');
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return genericResponse(STATUS_CODE.NotImplemented);
|
|
} catch (e) {
|
|
console.error(e);
|
|
return genericResponse(STATUS_CODE.InternalServerError);
|
|
}
|
|
});
|