Skip to content

Instantly share code, notes, and snippets.

@Lissy93
Last active Nov 3, 2022
Embed
What would you like to do?
CloudFlare Worker for Dashy Backup and Restore

This is the code for the backup / restore server used by Dashy. For more info, see Dashy Cloud Sync Docs.

All data needs to be encrypted before being sent to the backend. In Dashy, this is done in CloudBackup.js, using crypto.js's AES method, using the users chosen password as the key.

Getting Started

  1. Install Wrangler CLI Tool: npm i -g @cloudflare/wrangler
  2. Log into Cloudflare account: wrangler login
  3. Create a new project: wrangler generate my-project
  4. Install dependencies: cd my-project && npm i
  5. Populate wrangler.toml
  • Add your account_id (found on the right sidebar of the Workers or Overview Dashboard)
  • Add your zone_id (found in the Overview tab of your desired domain on Cloudflare)
  • Add your route, which should be a domain or host, supporting a wildcard
  1. Populate index.js with your code to handle requests
  2. To start the development server, run wrangler dev
  3. Finally, when you're ready to publish it, run wrangler publish

Endpoints

  • GET - Get config for a given user
    • backupId - The ID of the desired encrypted object
    • subHash - The latter half of the password hash, to verify ownership
  • POST - Save a new config object, and returns backupId
    • userData - The encrypted, compressed and stringified user config
    • subHash - The latter half of the password hash, to verify ownership
  • PUT - Update an existing config object
    • backupId - The ID of the object to update
    • subHash - Part of the hash, to verify ownership of said object
    • userData - The new data to store
  • DELETE - Delete a specified config object
    • backupId - The ID of the object to be deleted
    • subHash - Part of the password hash, to verify ownership of the object

For more details on using these endpoints, see the API Docs or import the Postman collection.

/* Access control headers */
const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, PUT, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'content-type': 'application/json;charset=UTF-8',
}
/* Listen for incoming requests, and call handler */
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
})
/* Depending on request type, call appropriate function */
async function handleRequest(request) {
const { method } = request;
switch (method) {
case 'GET': return getObject(request); // Return specified object
case 'POST': return addNewObject(request); // Add given object, return ID
case 'PUT': return updateObject(request); // Update specified object with given ID
case 'DELETE': return deleteObject(request); // Delete object for a given ID
default: return handleError(`Unexpected HTTP Method, ${method}`); // Terminate with error
}
}
/* Called when there is an error */
async function handleError(msg = 'Unknown Error') {
return new Response(
JSON.stringify({ errorMsg: `Error: ${msg}` }),
{ status: 200, headers }
);
}
/* Util to update the KV cache */
const setCache = (key, data) => DASHY_CLOUD_BACKUP.put(key, data);
/* Util to return a record from the KV cache */
const getCache = key => DASHY_CLOUD_BACKUP.get(key);
/* Generates a psudo-random string, used as identifiers */
const generateNewBackupId = (totalLen = 16) => {
// 1. Generate Random Characters (based on Math.random)
const letters = (len, str) => {
const newStr = () => String.fromCharCode(65 + Math.floor(Math.random() * 26));
return len <= 1 ? str : str + letters(len - 1, str + newStr());
};
// 2. Generate random numbers (based on time in milliseconds)
const numbers = (len) => Date.now().toString().substr(-len);
// 3. Shuffle order, with a multiplier of JavaScript's Math.random
const shuffled = (letters(totalLen) + numbers(totalLen))
.split('').sort(() => 0.5 - Math.random()).join('');
// 4. Concatinate, hyphonate and return
return shuffled.replace(/(....)./g, '$1-').slice(0, 19);
};
/* Returns CF Response object, with stringified JSON param, and response code */
const returnJsonResponse = (data, code) =>
new Response(
JSON.stringify(data),
{ status: code, headers }
);
/* Gets requesting IP address from headers */
const getIP = (req) => req.headers.get('CF-Connecting-IP');
/* Fetches and returns an object for corresponding backup ID */
async function getObject(request) {
const requestParams = (new URL(request.url)).searchParams;
const backupId = requestParams.get('backupId');
const subHash = requestParams.get('subHash');
if (!backupId) return handleError('Missing Backup ID');
const cache = await getCache(backupId);
if (!cache) return handleError(`No data found for '${backupId}'`);
let userData = {};
try {
userData = JSON.parse(cache);
} catch (e) {
return handleError(`Error parsing returned data, '${e}'`);
}
if (userData.subHash !== subHash) return handleError(`Incorrect Credentials for '${backupId}'`);
if (userData.ip && userData.ip !== getIP(request)) {
return handleError(`You do not have permission to access this record from your current IP`);
}
return returnJsonResponse({ msg: 'Data Returned', backupId, userData }, 200);
}
/* Validates and adds a new record */
async function addNewObject(request) {
const contentType = request.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return handleError('JSON was expected');
}
const requestParams = await request.json();
if (!requestParams) return handleError('Body content is missing');
const userData = requestParams.userData;
const subHash = requestParams.subHash;
const bindToIP = requestParams.bindToIp || false;
if (!userData) return handleError('Missing Parameter \'userData\'');
const backupId = generateNewBackupId();
if (await getCache(backupId)) return handleError('Conflicting ID generated, please try again');
const ip = bindToIP ? request.headers.get('CF-Connecting-IP') : null;
const record = JSON.stringify({
userData, backupId, subHash, ip,
});
await setCache(backupId, record);
return returnJsonResponse({ msg: 'Record Added', backupId }, 201);
}
/* Verifies and updates an existing record */
async function updateObject(request) {
// Check that a body is present, and in JSON form
const contentType = request.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return handleError('JSON was expected');
}
// Get and check request parameters
const requestParams = await request.json();
if (!requestParams) return handleError('Body content is missing');
const userData = requestParams.userData;
const backupId = requestParams.backupId;
const subHash = requestParams.subHash;
if (!userData) return handleError('Missing Parameter \'userData\'');
if (!backupId) return handleError('Missing Parameter \'backupId\'');
// Check credentials with the current record, to ensure it belongs to the user
const currentRecord = await getCache(backupId);
const parsedRecord = JSON.parse(currentRecord);
if (!currentRecord || !parsedRecord.userData) {
return handleError(`No record found for ID '${backupId}'`);
}
if (parsedRecord.subHash && parsedRecord.subHash !== subHash) {
return handleError('Incorrect Password');
}
if (parsedRecord.ip && parsedRecord.ip !== getIP(request)) {
return handleError('Request not allowed from this IP');
}
// Formulate the new record, and update into key store
const newRecord = JSON.stringify({
userData, backupId, subHash: parsedRecord.subHash, ip: parsedRecord.ip,
});
await setCache(backupId, newRecord);
return returnJsonResponse({ msg: 'Record Updated', backupId }, 201);
}
/* Verifies and deletes a specific record */
async function deleteObject(request) {
// Get parameters to find and check the record against
const requestParams = (new URL(request.url)).searchParams;
const backupId = requestParams.get('backupId');
const subHash = requestParams.get('subHash');
if (!backupId) return handleError('Missing Parameter \'backupId\'');
// Check that everything is kosha, and the user has permission
const currentRecord = await getCache(backupId);
if (!currentRecord) {
return handleError(`No record found for ID '${backupId}'`);
}
let parsedRecord = {};
try {
parsedRecord = JSON.parse(currentRecord);
} catch (e) {
return handleError('Record is malformed');
}
if (!parsedRecord) {
return handleError('Record not available');
}
if (parsedRecord.subHash && parsedRecord.subHash !== subHash) {
return handleError('Incorrect Password');
}
if (parsedRecord.ip && parsedRecord.ip !== getIP(request)) {
return handleError('Request not allowed from this IP');
}
// Proceed to the deletion, and return response
await setCache(backupId, null);
return new Response("Data has been deleted successfully", { status: 200, headers });
}
name = "dashy-worker"
type = "javascript"
workers_dev = true
route = "example.com/*"
zone_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
account_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
kv_namespaces = [
{ binding = "DASHY_CLOUD_BACKUP", id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
]
@Lissy93
Copy link
Author

Lissy93 commented Jun 19, 2021

Client-Side

The following functions are used to process user data, and make the requests to the worker hosted on CloudFlare

import sha256 from 'crypto-js/sha256';
import aes from 'crypto-js/aes';
import Utf8 from 'crypto-js/enc-utf8';
import axios from 'axios';
import { backupEndpoint } from '@/utils/defaults';

const ENDPOINT = backupEndpoint; // 'https://dashy-sync-service.as93.net';

/* Stringify, encrypt and encode data for transmission */
const encryptData = (data, password) => {
  const stringifiedData = JSON.stringify(data);
  const encryptedData = aes.encrypt(stringifiedData, password);
  return encryptedData.toString();
};

/* Decrypt, decode and parse received data */
const decryptData = (data, password) => aes.decrypt(data, password).toString(Utf8);

/* Returns a splice of the hash of the users password */
const makeSubHash = (pass) => sha256(pass).toString().slice(0, 14);

/* Makes the backup */
export const backup = (data, password) => axios.post(ENDPOINT, {
  userData: encryptData(data, password),
  subHash: makeSubHash(password),
});

/* Updates and existing backup */
export const update = (data, password, backupId) => axios.put(ENDPOINT, {
  backupId,
  userData: encryptData(data, password),
  subHash: makeSubHash(password),
});

/* Convert JSON into URL-endcoded GET parameters */
const encodeGetParams = p => Object.entries(p).map(kv => kv.map(encodeURIComponent).join('=')).join('&');

/* Restores the backup */
export const restore = (backupId, password) => {
  const params = encodeGetParams({ backupId, subHash: makeSubHash(password) });
  const url = `${ENDPOINT}/?${params}`;
  return new Promise((resolve, reject) => {
    axios.get(url).then((response) => {
      if (!response.data || response.data.errorMsg) {
        reject(response.data.errorMsg || 'Error');
      } else {
        const decryptedData = decryptData(response.data.userData.userData, password);
        try { resolve(JSON.parse(decryptedData)); } catch (e) { reject(e); }
      }
    });
  });
};

Web Component

Then you just need a form or web component to enable the user to initiate, update, restore from or delete a backup.

In Dashy, this Vue component is located in CloudBackupRestore.vue, and it's UI looks something like this:
Screenshot of Cloud Backup Restore Form

For more info, see Cloud Backup and Restore Docs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment