diff --git a/__tests__/backup.test.js b/__tests__/backup.test.js new file mode 100644 index 0000000..c420ffa --- /dev/null +++ b/__tests__/backup.test.js @@ -0,0 +1,84 @@ +const path = require("path"); +const { default: Prisma } = require("../db/dbconprisma"); + +require("dotenv").config({ + path: path.join(__dirname, "../", ".env"), +}); + +const { + restoreBackupData, + getPageDatawLinkAndSocialData, +} = require("../lib/dbfuncprisma"); +describe("Test backup function", () => { + beforeAll(async () => { + await Prisma.pagedata.deleteMany(); + await Prisma.socialdata.deleteMany(); + await Prisma.linkdata.deleteMany(); + await Prisma.pagedata.create({ + data: { + id: 1, + handlerText: "test", + avatarUrl: "", + avatarBorderColor: "#ffffff", + bgColor: "#7ea2ff", + accentColor: "#bdd7ff", + handlerFontSize: "20", + handlerFontColor: "#ffffff", + avatarwidth: "50", + footerBgColor: "#7ea2ff", + footerTextSize: "12", + footerText: "Test footer", + footerTextColor: "#ffffff", + handlerDescription: "Test description", + handlerDescriptionFontColor: "#ffffff", + linktreeWidth: "320", + linkdata: { + create: { + bgColor: "#2C6BED", + textColor: "#ffffff", + displayText: "Welcome to Linkin", + iconClass: "fas fa-link", + linkUrl: "https://github.com/RizkyRajitha/linkin", + }, + }, + socialdata: { + create: { + iconClass: "fab fa-github", + linkUrl: "https://github.com/RizkyRajitha/linkin", + bgColor: "#2C6BED", + borderRadius: "5", + }, + }, + }, + }); + }); + + beforeEach(() => { + jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterAll(async () => { + await Prisma.$disconnect(); + }); + + test("fails when no data provided", async () => { + await expect(restoreBackupData).rejects.toThrow("no data to backup"); + }); + + test("backups correctly with correct info", async () => { + const dataToBackup = { + pageData: { + id: 1, + handlerText: "Test Backup", + }, + socialData: [], + linkData: [], + }; + await restoreBackupData(dataToBackup); + let dbInfo = await getPageDatawLinkAndSocialData(); + + for (let n in dataToBackup.pageData) { + expect(dataToBackup.pageData[n]).toBe(dbInfo.pageData[n]); + } + }); +}); diff --git a/components/backup.js b/components/backup.js new file mode 100644 index 0000000..91bb345 --- /dev/null +++ b/components/backup.js @@ -0,0 +1,115 @@ +import { toast } from "react-toastify"; +import Swal from "sweetalert2"; + +const BackupComponent = () => { + const endpoint = + process.env.NODE_ENV === "production" ? `` : "http://localhost:3000"; + + const getBackup = async () => { + try { + let res = await fetch(`${endpoint}/api/user/backup`).then((res) => + res.json() + ); + let data = JSON.stringify(res, undefined, 2); + let blob = new Blob([data], { type: "application/json" }); + + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.style.display = "none"; + a.href = url; + + // filename = linkin-backup-date.json + let filename = `linkin-backup-${new Date() + .toISOString() + .slice(0, 16) + .replace(":", "-")}.json`; + a.download = filename; // set filename + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); // avoid memory leaks + } catch (error) { + toast.error(`${error.message}`, { autoClose: 10000 }); + } + }; + + const handleBackup = async (event) => { + event.preventDefault(); + + let file = document.getElementById("backup").files[0]; + if (!file) return toast.error(`No file selected`, { autoClose: 5000 }); + + let confirm = await Swal.fire({ + title: "Restore Data", + text: "You won't be able to revert this!", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Restore data", + }); + + if (!confirm.isConfirmed) return; + + let fr = new FileReader(); + + fr.onload = async function () { + const dataJson = JSON.parse(fr.result); + + await fetch(`${endpoint}/api/user/backup`, { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify(dataJson), + }) + .then(async (res) => { + console.log(res); + if (!res.ok) throw Error((await res.json()).message); + location.reload(); + }) // reload for getting restored page data + .catch((err) => { + toast.error(`${err.message}`, { autoClose: 10000 }); + toast.error(`File may be corrupted`, { autoClose: 10000 }); + }); + }; + + fr.readAsText(file); + }; + + return ( + <> +

Backup and Restore data

+
+
Backup
+ +
+ + + +
+
+ + ); +}; + +export default BackupComponent; diff --git a/components/generalform.js b/components/generalform.js index 0f318f3..e06e23e 100644 --- a/components/generalform.js +++ b/components/generalform.js @@ -2,6 +2,7 @@ import { useForm } from "react-hook-form"; import { isEmpty } from "../lib/side"; import styles from "../styles/form.module.css"; +import BackupForm from "./backup"; const GeneralForm = ({ data, update, loading }) => { const { @@ -245,6 +246,8 @@ const GeneralForm = ({ data, update, loading }) => { Save +
+ diff --git a/lib/dbfuncprisma.js b/lib/dbfuncprisma.js index 7067dc3..fad597b 100644 --- a/lib/dbfuncprisma.js +++ b/lib/dbfuncprisma.js @@ -138,6 +138,45 @@ export async function updatePageData(data) { } } +export async function restoreBackupData(data) { + if (!data) throw new Error("no data to backup"); + // get old data as temporal backup + let oldData = await getPageDatawLinkAndSocialData(); + + const deleteOldData = async () => { + await Promise.all([ + Prisma.linkdata.deleteMany(), + Prisma.socialdata.deleteMany(), + Prisma.pagedata.deleteMany(), + Prisma.$executeRaw("ALTER SEQUENCE linkdata_id_seq RESTART WITH 1;"), + Prisma.$executeRaw("ALTER SEQUENCE socialdata_id_seq RESTART WITH 1;"), + ]).catch((error) => { + throw new Error(error.message); + }); + }; + + await deleteOldData(); + + let { pageData, linkData, socialData } = data; + try { + await Prisma.pagedata.create({ data: pageData }); + await Prisma.linkdata.createMany({ data: linkData }); + await Prisma.socialdata.createMany({ data: socialData }); + return; + } catch (error) { + // try to recover previous data from backup + + // delete previous data (possibly created in try statement) + await deleteOldData(); + + let { pageData, linkData, socialData } = oldData; + await Prisma.pagedata.create({ data: pageData }); + await Prisma.linkdata.createMany({ data: linkData }); + await Prisma.socialdata.createMany({ data: socialData }); + throw new Error(error.message); + } +} + /** * insert PageLink * @param {*} data diff --git a/pages/api/user/backup.js b/pages/api/user/backup.js new file mode 100644 index 0000000..ab12335 --- /dev/null +++ b/pages/api/user/backup.js @@ -0,0 +1,54 @@ +import { jwtAuth, use } from "../../../middleware/middleware"; +import { + getPageDatawLinkAndSocialData, + restoreBackupData, +} from "../../../lib/dbfuncprisma"; + +// endpoint for download and restore backups +async function handler(req, res) { + // Run the middleware + await use(req, res, jwtAuth).catch((error) => { + console.log(error.message); + res.status(500).json({ success: false, message: error.message }); + }); + + switch (req.method) { + case "GET": + return await get(req, res); + case "POST": + return await post(req, res); + default: + res.status(400).send("method not allowed"); + return; + } +} + +async function get(req, res) { + try { + let data = await getPageDatawLinkAndSocialData(); + + res.json({ + ...data, + linkin_version: process.env.NEXT_PUBLIC_VERSION || "", + }); + } catch (error) { + console.log(error.message); + + res.status(500).json({ success: false, message: error.message }); + } +} + +async function post(req, res) { + try { + let { linkin_version, ...data } = req.body; + + await restoreBackupData(data); + + res.json({ success: true }); + } catch (error) { + console.log(error.message); + res.status(400).json({ success: false, message: error.message }); + } +} + +export default handler;