Skip to content
Snippets Groups Projects
Select Git revision
  • master default protected
1 result

app-sync.js

Blame
  • app-sync.js 12.00 KiB
    let { Buffer } = require('buffer');
    let fs = require('fs/promises');
    let { join } = require('path');
    let express = require('express');
    let uuid = require('uuid');
    let AdmZip = require('adm-zip');
    let { validateUser } = require('./util/validate-user');
    let errorMiddleware = require('./util/error-middleware');
    let config = require('./load-config');
    let { getAccountDb } = require('./account-db');
    
    let simpleSync = require('./sync-simple');
    let fullSync = require('./sync-full');
    
    let actual = require('@actual-app/api');
    let SyncPb = actual.internal.SyncProtoBuf;
    
    const app = express();
    app.use(errorMiddleware);
    
    async function init() {
      await actual.init({
        config: {
          dataDir: join(__dirname, 'user-files')
        }
      });
    }
    
    // This is a version representing the internal format of sync
    // messages. When this changes, all sync files need to be reset. We
    // will check this version when syncing and notify the user if they
    // need to reset.
    const SYNC_FORMAT_VERSION = 2;
    
    app.post('/sync', async (req, res) => {
      let user = validateUser(req, res);
      if (!user) {
        return;
      }
    
      let requestPb;
      try {
        requestPb = SyncPb.SyncRequest.deserializeBinary(req.body);
      } catch (e) {
        res.status(500);
        res.send({ status: 'error', reason: 'internal-error' });
        return;
      }
    
      let accountDb = getAccountDb();
      let file_id = requestPb.getFileid() || null;
      let group_id = requestPb.getGroupid() || null;
      let key_id = requestPb.getKeyid() || null;
      let since = requestPb.getSince() || null;
      let messages = requestPb.getMessagesList();
    
      if (!since) {
        throw new Error('`since` is required');
      }
    
      let currentFiles = accountDb.all(
        'SELECT group_id, encrypt_keyid, encrypt_meta, sync_version FROM files WHERE id = ?',
        [file_id]
      );
    
      if (currentFiles.length === 0) {
        res.status(400);
        res.send('file-not-found');
        return;
      }
    
      let currentFile = currentFiles[0];
    
      if (
        currentFile.sync_version == null ||
        currentFile.sync_version < SYNC_FORMAT_VERSION
      ) {
        res.status(400);
        res.send('file-old-version');
        return;
      }
    
      // When resetting sync state, something went wrong. There is no
      // group id and it's awaiting a file to be uploaded.
      if (currentFile.group_id == null) {
        res.status(400);
        res.send('file-needs-upload');
        return;
      }
    
      // Check to make sure the uploaded file is valid and has been
      // encrypted with the same key it is registered with (this might
      // be wrong if there was an error during the key creation
      // process)
      let uploadedKeyId = currentFile.encrypt_meta
        ? JSON.parse(currentFile.encrypt_meta).keyId
        : null;
      if (uploadedKeyId !== currentFile.encrypt_keyid) {
        res.status(400);
        res.send('file-key-mismatch');
        return;
      }
    
      // The changes being synced are part of an old group, which
      // means the file has been reset. User needs to re-download.
      if (group_id !== currentFile.group_id) {
        res.status(400);
        res.send('file-has-reset');
        return;
      }
    
      // The data is encrypted with a different key which is
      // unacceptable. We can't accept these changes. Reject them and
      // tell the user that they need to generate the correct key
      // (which necessitates a sync reset so they need to re-download).
      if (key_id !== currentFile.encrypt_keyid) {
        res.status(400);
        res.send('file-has-new-key');
        return false;
      }
    
      // TODO: We also provide a "simple" sync method which currently isn't
      // used. This method just stores the messages locally and doesn't
      // load the whole app at all. If we want to support end-to-end
      // encryption, this method is required because we can't read the
      // messages. Using it looks like this:
      //
      // let simpleSync = require('./sync-simple');
      // let {trie, newMessages } = simpleSync.sync(messages, since, file_id);
    
      let { trie, newMessages } = await fullSync.sync(messages, since, file_id);
    
      // encode it back...
      let responsePb = new SyncPb.SyncResponse();
      responsePb.setMerkle(JSON.stringify(trie));
    
      for (let i = 0; i < newMessages.length; i++) {
        let msg = newMessages[i];
        let envelopePb = new SyncPb.MessageEnvelope();
        envelopePb.setTimestamp(msg.timestamp);
        envelopePb.setIsencrypted(msg.is_encrypted === 1);
        envelopePb.setContent(msg.content);
        responsePb.addMessages(envelopePb);
      }
    
      res.set('Content-Type', 'application/actual-sync');
      res.send(Buffer.from(responsePb.serializeBinary()));
    });
    
    app.post('/user-get-key', (req, res) => {
      let user = validateUser(req, res);
      if (!user) {
        return;
      }
    
      let accountDb = getAccountDb();
      let { fileId } = req.body;
    
      let rows = accountDb.all(
        'SELECT encrypt_salt, encrypt_keyid, encrypt_test FROM files WHERE id = ?',
        [fileId]
      );
      if (rows.length === 0) {
        res.status(400).send('file-not-found');
        return;
      }
      let { encrypt_salt, encrypt_keyid, encrypt_test } = rows[0];
    
      res.send(
        JSON.stringify({
          status: 'ok',
          data: { id: encrypt_keyid, salt: encrypt_salt, test: encrypt_test }
        })
      );
    });
    
    app.post('/user-create-key', (req, res) => {
      let user = validateUser(req, res);
      if (!user) {
        return;
      }
      let accountDb = getAccountDb();
      let { fileId, keyId, keySalt, testContent } = req.body;
    
      accountDb.mutate(
        'UPDATE files SET encrypt_salt = ?, encrypt_keyid = ?, encrypt_test = ? WHERE id = ?',
        [keySalt, keyId, testContent, fileId]
      );
    
      res.send(JSON.stringify({ status: 'ok' }));
    });
    
    app.post('/reset-user-file', (req, res) => {
      let user = validateUser(req, res);
      if (!user) {
        return;
      }
      let accountDb = getAccountDb();
      let { fileId } = req.body;
    
      let files = accountDb.all('SELECT group_id FROM files WHERE id = ?', [
        fileId
      ]);
      if (files.length === 0) {
        res.status(400).send('User or file not found');
        return;
      }
      let { group_id } = files[0];
    
      accountDb.mutate('UPDATE files SET group_id = NULL WHERE id = ?', [fileId]);
    
      if (group_id) {
        // TODO: Instead of doing this, just delete the db file named
        // after the group
        // db.mutate('DELETE FROM messages_binary WHERE group_id = ?', [group_id]);
        // db.mutate('DELETE FROM messages_merkles WHERE group_id = ?', [group_id]);
      }
    
      res.send(JSON.stringify({ status: 'ok' }));
    });
    
    app.post('/upload-user-file', async (req, res) => {
      let user = validateUser(req, res);
      if (!user) {
        return;
      }
    
      let accountDb = getAccountDb();
      let name = decodeURIComponent(req.headers['x-actual-name']);
      let fileId = req.headers['x-actual-file-id'];
      let groupId = req.headers['x-actual-group-id'] || null;
      let encryptMeta = req.headers['x-actual-encrypt-meta'] || null;
      let syncFormatVersion = req.headers['x-actual-format'] || null;
    
      let keyId = encryptMeta ? JSON.parse(encryptMeta).keyId : null;
    
      if (!fileId) {
        throw new Error('fileId is required');
      }
    
      let currentFiles = accountDb.all(
        'SELECT group_id, encrypt_keyid, encrypt_meta FROM files WHERE id = ?',
        [fileId]
      );
      if (currentFiles.length) {
        let currentFile = currentFiles[0];
    
        // The uploading file is part of an old group, so reject
        // it. All of its internal sync state is invalid because its
        // old. The sync state has been reset, so user needs to
        // either reset again or download from the current group.
        if (groupId !== currentFile.group_id) {
          res.status(400);
          res.send('file-has-reset');
          return;
        }
    
        // The key that the file is encrypted with is different than
        // the current registered key. All data must always be
        // encrypted with the registered key for consistency. Key
        // changes always necessitate a sync reset, which means this
        // upload is trying to overwrite another reset. That might
        // be be fine, but since we definitely cannot accept a file
        // encrypted with the wrong key, we bail and suggest the
        // user download the latest file.
        if (keyId !== currentFile.encrypt_keyid) {
          res.status(400);
          res.send('file-has-new-key');
          return;
        }
      }
    
      // TODO: If we want to support end-to-end encryption, we'd write the
      // raw file down because it's an encrypted blob. This isn't
      // supported yet in the self-hosted version because it's unclear if
      // it's still needed, given that you own your server
      //
      // await fs.writeFile(join(config.files, `${fileId}.blob`), req.body);
    
      let zip = new AdmZip(req.body);
    
      try {
        zip.extractAllTo(join(config.files, fileId), true);
      } catch (err) {
        console.log('Error writing file', err);
        res.send(JSON.stringify({ status: 'error' }));
        return;
      }
    
      let rows = accountDb.all('SELECT id FROM files WHERE id = ?', [fileId]);
      if (rows.length === 0) {
        // it's new
        groupId = uuid.v4();
        accountDb.mutate(
          'INSERT INTO files (id, group_id, sync_version, name, encrypt_meta) VALUES (?, ?, ?, ?, ?)',
          [fileId, groupId, syncFormatVersion, name, encryptMeta]
        );
        res.send(JSON.stringify({ status: 'ok', groupId }));
      } else {
        if (!groupId) {
          // sync state was reset, create new group
          groupId = uuid.v4();
          accountDb.mutate('UPDATE files SET group_id = ? WHERE id = ?', [
            groupId,
            fileId
          ]);
        }
    
        // Regardless, update some properties
        accountDb.mutate(
          'UPDATE files SET sync_version = ?, encrypt_meta = ?, name = ? WHERE id = ?',
          [syncFormatVersion, encryptMeta, name, fileId]
        );
        res.send(JSON.stringify({ status: 'ok', groupId }));
      }
    });
    
    app.get('/download-user-file', async (req, res) => {
      let user = validateUser(req, res);
      if (!user) {
        return;
      }
      let accountDb = getAccountDb();
      let fileId = req.headers['x-actual-file-id'];
    
      // Do some authentication
      let rows = accountDb.all(
        'SELECT id FROM files WHERE id = ? AND deleted = FALSE',
        [fileId]
      );
      if (rows.length === 0) {
        res.status(400).send('User or file not found');
        return;
      }
    
      let zip = new AdmZip();
      try {
        zip.addLocalFolder(join(config.files, fileId), '/');
      } catch (e) {
        res.status(500).send('Error reading files');
        return;
      }
      let buffer = zip.toBuffer();
    
      res.setHeader('Content-Disposition', `attachment;filename=${fileId}`);
      res.send(buffer);
    });
    
    app.post('/update-user-filename', (req, res) => {
      let user = validateUser(req, res);
      if (!user) {
        return;
      }
      let accountDb = getAccountDb();
      let { fileId, name } = req.body;
    
      // Do some authentication
      let rows = accountDb.all(
        'SELECT id FROM files WHERE id = ? AND deleted = FALSE',
        [fileId]
      );
      if (rows.length === 0) {
        res.status(500).send('User or file not found');
        return;
      }
    
      accountDb.mutate('UPDATE files SET name = ? WHERE id = ?', [name, fileId]);
    
      res.send(JSON.stringify({ status: 'ok' }));
    });
    
    app.get('/list-user-files', (req, res) => {
      let user = validateUser(req, res);
      if (!user) {
        return;
      }
    
      let accountDb = getAccountDb();
      let rows = accountDb.all('SELECT * FROM files');
    
      res.send(
        JSON.stringify({
          status: 'ok',
          data: rows.map(row => ({
            deleted: row.deleted,
            fileId: row.id,
            groupId: row.group_id,
            name: row.name,
            encryptKeyId: row.encrypt_keyid
          }))
        })
      );
    });
    
    app.get('/get-user-file-info', (req, res) => {
      let user = validateUser(req, res);
      if (!user) {
        return;
      }
      let accountDb = getAccountDb();
      let fileId = req.headers['x-actual-file-id'];
    
      let rows = accountDb.all(
        'SELECT * FROM files WHERE id = ? AND deleted = FALSE',
        [fileId]
      );
      if (rows.length === 0) {
        res.send(JSON.stringify({ status: 'error' }));
        return;
      }
      let row = rows[0];
    
      res.send(
        JSON.stringify({
          status: 'ok',
          data: {
            deleted: row.deleted,
            fileId: row.id,
            groupId: row.group_id,
            name: row.name,
            encryptMeta: row.encrypt_meta ? JSON.parse(row.encrypt_meta) : null
          }
        })
      );
    });
    
    app.post('/delete-user-file', (req, res) => {
      let user = validateUser(req, res);
      if (!user) {
        return;
      }
      let accountDb = getAccountDb();
      let { fileId } = req.body;
    
      accountDb.mutate('UPDATE files SET deleted = TRUE WHERE id = ?', [fileId]);
      res.send(JSON.stringify({ status: 'ok' }));
    });
    
    module.exports.handlers = app;
    module.exports.init = init;