Dashy Cloud Backup & Restore Worker

wrangler.toml

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" }
]

index.js

/* 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 });
}