Compare commits
48 commits
actual-4.1
...
master
Author | SHA1 | Date | |
---|---|---|---|
97081d46c4 | |||
60f83b334e | |||
340ac869ce | |||
b2b6ba4921 | |||
3274a06e34 | |||
e9850bfc56 | |||
529c42cccf | |||
2a00227486 | |||
5252edbf70 | |||
74c15b4f42 | |||
d482b9baf6 | |||
cde216523e | |||
8aeb815b5a | |||
3c602268e3 | |||
9177fb4d77 | |||
e3f1fafad9 | |||
32bf923c1a | |||
d3a0e8067e | |||
105d5007cf | |||
bafa486668 | |||
80a2b34d43 | |||
a5e1e38e74 | |||
0486e9e37b | |||
09722d8678 | |||
cd22e38660 | |||
f83fe76280 | |||
b9e1e6030f | |||
4874b53c7c | |||
b1a48f4f27 | |||
3fee9cbb42 | |||
a2a460a883 | |||
6bcd67a906 | |||
9e2d253fb6 | |||
a7efc82944 | |||
25f4bb5557 | |||
74d6b7edc5 | |||
1204b5b1a6 | |||
59ddc965ec | |||
9cc4ffaf33 | |||
11ba63d086 | |||
06d2aba57c | |||
7ecaad529f | |||
5ef3aa4153 | |||
5e83e14637 | |||
8dbc10efd7 | |||
592f0540f9 | |||
0e28f77a1f | |||
618609dbfa |
5
.eslintignore
Normal file
5
.eslintignore
Normal file
|
@ -0,0 +1,5 @@
|
|||
**/node_modules/*
|
||||
**/log/*
|
||||
**/shared/*
|
||||
|
||||
supervise
|
22
.eslintrc.js
Normal file
22
.eslintrc.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
amd: true,
|
||||
node: true
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint', 'prettier'],
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_'
|
||||
}
|
||||
],
|
||||
|
||||
'@typescript-eslint/no-var-requires': 'off'
|
||||
}
|
||||
};
|
94
.github/workflows/build.yml
vendored
94
.github/workflows/build.yml
vendored
|
@ -1,85 +1,29 @@
|
|||
name: Build Docker Image
|
||||
name: Build
|
||||
|
||||
# Docker Images are only built when a new tag is created
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: |
|
||||
jlongster/actual-server
|
||||
ghcr.io/actualbudget/actual-server
|
||||
# Creates the following tags:
|
||||
# - actual-server:latest
|
||||
# - actual-server:1.3
|
||||
# - actual-server:1.3.7
|
||||
# - actual-server:sha-90dd603
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Docker meta for Alpine image
|
||||
id: alpine-meta
|
||||
uses: docker/metadata-action@v4
|
||||
node-version: 16
|
||||
- name: Cache
|
||||
uses: actions/cache@v2
|
||||
id: cache
|
||||
with:
|
||||
images: |
|
||||
jlongster/actual-server
|
||||
ghcr.io/actualbudget/actual-server
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=raw,value=latest,suffix=-alpine
|
||||
type=semver,pattern={{version}},suffix=-alpine
|
||||
type=semver,pattern={{major}}.{{minor}},suffix=-alpine
|
||||
type=sha,suffix=-alpine
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push standard image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
- name: Build and push Alpine image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
file: Dockerfile.alpine
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.alpine-meta.outputs.tags }}
|
||||
path: '**/node_modules'
|
||||
key: yarn-v1-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Install
|
||||
run: yarn --immutable
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
|
93
.github/workflows/docker.yml
vendored
Normal file
93
.github/workflows/docker.yml
vendored
Normal file
|
@ -0,0 +1,93 @@
|
|||
name: Build Docker Image
|
||||
|
||||
# Docker Images are built for every push to master or when a new tag is created
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths-ignore:
|
||||
- README.md
|
||||
- LICENSE.txt
|
||||
|
||||
env:
|
||||
IMAGES: |
|
||||
jlongster/actual-server
|
||||
ghcr.io/actualbudget/actual-server
|
||||
|
||||
# Creates the following tags:
|
||||
# - actual-server:latest (see docker/metadata-action flavor inputs, below)
|
||||
# - actual-server:edge (for master)
|
||||
# - actual-server:1.3
|
||||
# - actual-server:1.3.7
|
||||
# - actual-server:sha-90dd603
|
||||
TAGS: |
|
||||
type=edge,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=sha
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
# Automatically update :latest for our semver tags
|
||||
flavor: |
|
||||
latest=auto
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Docker meta for Alpine image
|
||||
id: alpine-meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.IMAGES }}
|
||||
# Automatically update :latest for our semver tags and suffix all tags
|
||||
flavor: |
|
||||
latest=auto
|
||||
suffix=-alpine,onlatest=true
|
||||
tags: $${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push standard image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
- name: Build and push Alpine image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
file: Dockerfile.alpine
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.alpine-meta.outputs.tags }}
|
29
.github/workflows/lint.yml
vendored
Normal file
29
.github/workflows/lint.yml
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
name: Linter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Cache
|
||||
uses: actions/cache@v2
|
||||
id: cache
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: yarn-v1-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Install
|
||||
run: yarn --immutable
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
- name: Lint
|
||||
run: yarn lint
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -7,4 +7,5 @@ supervise
|
|||
bin/large-sync-data.txt
|
||||
user-files
|
||||
server-files
|
||||
fly.toml
|
||||
fly.toml
|
||||
build/
|
||||
|
|
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none"
|
||||
}
|
|
@ -8,7 +8,9 @@ let { getAccountDb } = require('./account-db');
|
|||
let app = express();
|
||||
app.use(errorMiddleware);
|
||||
|
||||
function init() {}
|
||||
function init() {
|
||||
// eslint-disable-previous-line @typescript-eslint/no-empty-function
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
return bcrypt.hashSync(password, 12);
|
||||
|
|
|
@ -190,7 +190,7 @@ app.post(
|
|||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Actual Budget'
|
||||
}
|
||||
}).then(res => res.json());
|
||||
}).then((res) => res.json());
|
||||
|
||||
await req.runQuery(
|
||||
'INSERT INTO access_tokens (item_id, user_id, access_token) VALUES ($1, $2, $3)',
|
||||
|
@ -233,7 +233,7 @@ app.post(
|
|||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Actual Budget'
|
||||
}
|
||||
}).then(res => res.json());
|
||||
}).then((res) => res.json());
|
||||
|
||||
if (resData.removed !== true) {
|
||||
console.log('[Error] Item not removed: ' + access_token.slice(0, 3));
|
||||
|
@ -286,7 +286,7 @@ app.post(
|
|||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Actual Budget'
|
||||
}
|
||||
}).then(res => res.json());
|
||||
}).then((res) => res.json());
|
||||
|
||||
res.send(
|
||||
JSON.stringify({
|
||||
|
@ -342,7 +342,7 @@ app.post(
|
|||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Actual Budget'
|
||||
}
|
||||
}).then(res => res.json());
|
||||
}).then((res) => res.json());
|
||||
|
||||
res.send(
|
||||
JSON.stringify({
|
||||
|
|
76
app-sync.js
76
app-sync.js
|
@ -1,16 +1,13 @@
|
|||
let { Buffer } = require('buffer');
|
||||
let fs = require('fs/promises');
|
||||
let { join } = require('path');
|
||||
let { Buffer } = require('buffer');
|
||||
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 { getPathForUserFile, getPathForGroupFile } = require('./util/paths');
|
||||
|
||||
let simpleSync = require('./sync-simple');
|
||||
let fullSync = require('./sync-full');
|
||||
|
||||
let actual = require('@actual-app/api');
|
||||
let SyncPb = actual.internal.SyncProtoBuf;
|
||||
|
@ -18,17 +15,8 @@ let SyncPb = actual.internal.SyncProtoBuf;
|
|||
const app = express();
|
||||
app.use(errorMiddleware);
|
||||
|
||||
async function init() {
|
||||
let fileDir = join(process.env.ACTUAL_USER_FILES || config.userFiles);
|
||||
|
||||
console.log('Initializing Actual with user file dir:', fileDir);
|
||||
|
||||
await actual.init({
|
||||
config: {
|
||||
dataDir: fileDir
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
async function init() {}
|
||||
|
||||
// This is a version representing the internal format of sync
|
||||
// messages. When this changes, all sync files need to be reset. We
|
||||
|
@ -123,31 +111,15 @@ app.post('/sync', async (req, res) => {
|
|||
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);
|
||||
let { trie, newMessages } = simpleSync.sync(messages, since, group_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);
|
||||
}
|
||||
newMessages.forEach((msg) => responsePb.addMessages(msg));
|
||||
|
||||
res.set('Content-Type', 'application/actual-sync');
|
||||
res.set('X-ACTUAL-SYNC-METHOD', 'simple');
|
||||
res.send(Buffer.from(responsePb.serializeBinary()));
|
||||
});
|
||||
|
||||
|
@ -194,7 +166,7 @@ app.post('/user-create-key', (req, res) => {
|
|||
res.send(JSON.stringify({ status: 'ok' }));
|
||||
});
|
||||
|
||||
app.post('/reset-user-file', (req, res) => {
|
||||
app.post('/reset-user-file', async (req, res) => {
|
||||
let user = validateUser(req, res);
|
||||
if (!user) {
|
||||
return;
|
||||
|
@ -214,10 +186,11 @@ app.post('/reset-user-file', (req, res) => {
|
|||
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]);
|
||||
try {
|
||||
await fs.unlink(getPathForGroupFile(group_id));
|
||||
} catch (e) {
|
||||
console.log(`Unable to delete sync data for group "${group_id}"`);
|
||||
}
|
||||
}
|
||||
|
||||
res.send(JSON.stringify({ status: 'ok' }));
|
||||
|
@ -274,21 +247,11 @@ app.post('/upload-user-file', async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 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.userFiles, `${fileId}.blob`), req.body);
|
||||
|
||||
let zip = new AdmZip(req.body);
|
||||
|
||||
try {
|
||||
zip.extractAllTo(join(config.userFiles, fileId), true);
|
||||
await fs.writeFile(getPathForUserFile(fileId), req.body);
|
||||
} 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]);
|
||||
|
@ -315,6 +278,7 @@ app.post('/upload-user-file', async (req, res) => {
|
|||
'UPDATE files SET sync_version = ?, encrypt_meta = ?, name = ? WHERE id = ?',
|
||||
[syncFormatVersion, encryptMeta, name, fileId]
|
||||
);
|
||||
|
||||
res.send(JSON.stringify({ status: 'ok', groupId }));
|
||||
}
|
||||
});
|
||||
|
@ -337,14 +301,14 @@ app.get('/download-user-file', async (req, res) => {
|
|||
return;
|
||||
}
|
||||
|
||||
let zip = new AdmZip();
|
||||
let buffer;
|
||||
try {
|
||||
zip.addLocalFolder(join(config.userFiles, fileId), '/');
|
||||
buffer = await fs.readFile(getPathForUserFile(fileId));
|
||||
} catch (e) {
|
||||
res.status(500).send('Error reading files');
|
||||
console.log(`Error: file does not exist: ${getPathForUserFile(fileId)}`);
|
||||
res.status(500).send('File does not exist on server');
|
||||
return;
|
||||
}
|
||||
let buffer = zip.toBuffer();
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment;filename=${fileId}`);
|
||||
res.send(buffer);
|
||||
|
@ -385,7 +349,7 @@ app.get('/list-user-files', (req, res) => {
|
|||
res.send(
|
||||
JSON.stringify({
|
||||
status: 'ok',
|
||||
data: rows.map(row => ({
|
||||
data: rows.map((row) => ({
|
||||
deleted: row.deleted,
|
||||
fileId: row.id,
|
||||
groupId: row.group_id,
|
||||
|
|
15
app.js
15
app.js
|
@ -10,7 +10,7 @@ const syncApp = require('./app-sync');
|
|||
|
||||
const app = express();
|
||||
|
||||
process.on('unhandledRejection', reason => {
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.log('Rejection:', reason);
|
||||
});
|
||||
|
||||
|
@ -29,7 +29,16 @@ app.get('/mode', (req, res) => {
|
|||
app.use(actuator()); // Provides /health, /metrics, /info
|
||||
|
||||
// The web frontend
|
||||
app.use(express.static(__dirname + '/node_modules/@actual-app/web/build'));
|
||||
app.use((req, res, next) => {
|
||||
res.set('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
res.set('Cross-Origin-Embedder-Policy', 'require-corp');
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
express.static(__dirname + '/node_modules/@actual-app/web/build', {
|
||||
index: false
|
||||
})
|
||||
);
|
||||
app.get('/*', (req, res) => {
|
||||
res.sendFile(__dirname + '/node_modules/@actual-app/web/build/index.html');
|
||||
});
|
||||
|
@ -50,7 +59,7 @@ async function run() {
|
|||
app.listen(config.port, config.hostname);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
run().catch((err) => {
|
||||
console.log('Error starting app:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
@ -6,10 +6,12 @@ processes = []
|
|||
|
||||
[env]
|
||||
PORT = "5006"
|
||||
TINI_SUBREAPER = 1
|
||||
|
||||
[experimental]
|
||||
allowed_public_ports = []
|
||||
auto_rollback = true
|
||||
cmd = ["node", "--max-old-space-size=180", "app.js"]
|
||||
|
||||
[[services]]
|
||||
http_checks = []
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
let config;
|
||||
try {
|
||||
// @ts-expect-error TS2307: we expect this file may not exist
|
||||
config = require('./config');
|
||||
} catch (e) {
|
||||
let fs = require('fs');
|
||||
|
@ -15,4 +16,7 @@ try {
|
|||
};
|
||||
}
|
||||
|
||||
// The env variable always takes precedence
|
||||
config.userFiles = process.env.ACTUAL_USER_FILES || config.userFiles;
|
||||
|
||||
module.exports = config;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export default async function runMigration(db, uuid) {
|
||||
export default async function runMigration(db) {
|
||||
function getValue(node) {
|
||||
return node.expr != null ? node.expr : node.cachedValue;
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
|||
true
|
||||
);
|
||||
db.transaction(() => {
|
||||
budget.map(monthBudget => {
|
||||
budget.map((monthBudget) => {
|
||||
let match = monthBudget.name.match(
|
||||
/^(budget-report|budget)(\d+)!budget-(.+)$/
|
||||
);
|
||||
|
@ -84,7 +84,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
|||
true
|
||||
);
|
||||
db.transaction(() => {
|
||||
buffers.map(buffer => {
|
||||
buffers.map((buffer) => {
|
||||
let match = buffer.name.match(/^budget(\d+)!buffered$/);
|
||||
if (match) {
|
||||
let month = match[1].slice(0, 4) + '-' + match[1].slice(4);
|
||||
|
@ -108,7 +108,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
|||
true
|
||||
);
|
||||
|
||||
let parseNote = str => {
|
||||
let parseNote = (str) => {
|
||||
try {
|
||||
let value = JSON.parse(str);
|
||||
return value && value !== '' ? value : null;
|
||||
|
@ -118,7 +118,7 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL);
|
|||
};
|
||||
|
||||
db.transaction(() => {
|
||||
notes.forEach(note => {
|
||||
notes.forEach((note) => {
|
||||
let parsed = parseNote(getValue(note));
|
||||
if (parsed) {
|
||||
let [, id] = note.name.split('!');
|
||||
|
|
36
package.json
36
package.json
|
@ -1,41 +1,37 @@
|
|||
{
|
||||
"name": "actual-sync",
|
||||
"version": "1.0.0",
|
||||
"version": "22.12.09",
|
||||
"license": "MIT",
|
||||
"description": "actual syncing server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node app",
|
||||
"lint": "eslint --ignore-pattern '**/node_modules/*' --ignore-pattern '**/log/*' --ignore-pattern 'supervise' --ignore-pattern '**/shared/*' ."
|
||||
"lint": "eslint .",
|
||||
"build": "tsc",
|
||||
"types": "tsc --noEmit --incremental",
|
||||
"verify": "yarn -s lint && yarn types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/api": "^4.0.1",
|
||||
"@actual-app/web": "^4.0.2",
|
||||
"adm-zip": "^0.5.9",
|
||||
"@actual-app/api": "4.1.5",
|
||||
"@actual-app/web": "22.12.3",
|
||||
"bcrypt": "^5.0.1",
|
||||
"better-sqlite3": "^7.5.0",
|
||||
"body-parser": "^1.18.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.16.3",
|
||||
"express": "4.17",
|
||||
"express-actuator": "^1.8.1",
|
||||
"express-response-size": "^0.0.3",
|
||||
"node-fetch": "^2.2.0",
|
||||
"uuid": "^3.3.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-eslint": "^10.0.1",
|
||||
"eslint": "^5.12.1",
|
||||
"eslint-config-react-app": "^3.0.6",
|
||||
"eslint-plugin-flowtype": "^3.2.1",
|
||||
"eslint-plugin-import": "^2.14.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.1.2",
|
||||
"eslint-plugin-react": "^7.12.4"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none"
|
||||
"@types/better-sqlite3": "^7.5.0",
|
||||
"@types/node": "^17.0.31",
|
||||
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
||||
"@typescript-eslint/parser": "^5.23.0",
|
||||
"eslint": "^8.15.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"prettier": "^2.6.2",
|
||||
"typescript": "^4.6.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
|
||||
CREATE TABLE messages_binary
|
||||
(timestamp TEXT,
|
||||
(timestamp TEXT PRIMARY KEY,
|
||||
is_encrypted BOOLEAN,
|
||||
content bytea,
|
||||
PRIMARY KEY(timestamp, group_id));
|
||||
content bytea);
|
||||
|
||||
CREATE TABLE messages_merkles
|
||||
(id TEXT PRIMAREY KEY,
|
||||
(id INTEGER PRIMARY KEY,
|
||||
merkle TEXT);
|
||||
|
|
21
sync-full.js
21
sync-full.js
|
@ -15,7 +15,7 @@ const sync = sequential(async function syncAPI(messages, since, fileId) {
|
|||
await actual.internal.send('load-budget', { id: fileId });
|
||||
}
|
||||
|
||||
messages = messages.map(envPb => {
|
||||
messages = messages.map((envPb) => {
|
||||
let timestamp = envPb.getTimestamp();
|
||||
let msg = SyncPb.Message.deserializeBinary(envPb.getContent());
|
||||
return {
|
||||
|
@ -27,11 +27,26 @@ const sync = sequential(async function syncAPI(messages, since, fileId) {
|
|||
};
|
||||
});
|
||||
|
||||
let newMessages = actual.internal.syncAndReceiveMessages(messages, since);
|
||||
const newMessages = await actual.internal.syncAndReceiveMessages(
|
||||
messages,
|
||||
since
|
||||
);
|
||||
|
||||
return {
|
||||
trie: actual.internal.timestamp.getClock().merkle,
|
||||
newMessages: newMessages
|
||||
newMessages: newMessages.map((msg) => {
|
||||
const envelopePb = new SyncPb.MessageEnvelope();
|
||||
|
||||
const messagePb = new SyncPb.Message();
|
||||
messagePb.setDataset(msg.dataset);
|
||||
messagePb.setRow(msg.row);
|
||||
messagePb.setColumn(msg.column);
|
||||
messagePb.setValue(msg.value);
|
||||
envelopePb.setTimestamp(msg.timestamp);
|
||||
|
||||
envelopePb.setContent(messagePb.serializeBinary());
|
||||
return envelopePb;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
let { existsSync, readFileSync } = require('fs');
|
||||
let { join } = require('path');
|
||||
let { openDatabase } = require('./db');
|
||||
let { getPathForGroupFile } = require('./util/paths');
|
||||
|
||||
let actual = require('@actual-app/api');
|
||||
let merkle = actual.internal.merkle;
|
||||
let SyncPb = actual.internal.SyncProtoBuf;
|
||||
let Timestamp = actual.internal.timestamp.Timestamp;
|
||||
|
||||
function getGroupDb(groupId) {
|
||||
let path = join(__dirname, `user-files/${groupId}.sqlite`);
|
||||
let path = getPathForGroupFile(groupId);
|
||||
let needsInit = !existsSync(path);
|
||||
|
||||
let db = openDatabase(path);
|
||||
|
@ -56,8 +58,8 @@ function addMessages(db, messages) {
|
|||
return returnValue;
|
||||
}
|
||||
|
||||
function getMerkle(db, group_id) {
|
||||
let rows = db.all('SELECT * FROM messages_merkles', [group_id]);
|
||||
function getMerkle(db) {
|
||||
let rows = db.all('SELECT * FROM messages_merkles');
|
||||
|
||||
if (rows.length > 0) {
|
||||
return JSON.parse(rows[0].merkle);
|
||||
|
@ -68,19 +70,29 @@ function getMerkle(db, group_id) {
|
|||
}
|
||||
}
|
||||
|
||||
function sync(messages, since, fileId) {
|
||||
let db = getGroupDb(fileId);
|
||||
function sync(messages, since, groupId) {
|
||||
let db = getGroupDb(groupId);
|
||||
let newMessages = db.all(
|
||||
`SELECT * FROM messages_binary
|
||||
WHERE timestamp > ?
|
||||
ORDER BY timestamp`,
|
||||
[since],
|
||||
true
|
||||
[since]
|
||||
);
|
||||
|
||||
let trie = addMessages(db, messages);
|
||||
|
||||
return { trie, newMessages };
|
||||
db.close();
|
||||
|
||||
return {
|
||||
trie,
|
||||
newMessages: newMessages.map((msg) => {
|
||||
const envelopePb = new SyncPb.MessageEnvelope();
|
||||
envelopePb.setTimestamp(msg.timestamp);
|
||||
envelopePb.setIsencrypted(msg.is_encrypted);
|
||||
envelopePb.setContent(msg.content);
|
||||
return envelopePb;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { sync };
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
// DOM for URL global in Node 16+
|
||||
"lib": ["ES2021", "DOM"],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"resolveJsonModule": true,
|
||||
"downlevelIteration": true,
|
||||
|
@ -11,10 +12,9 @@
|
|||
// Check JS files too
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
// Used for temp builds
|
||||
"outDir": "build",
|
||||
"moduleResolution": "Node",
|
||||
"module": "ESNext"
|
||||
"moduleResolution": "node",
|
||||
"module": "commonjs",
|
||||
"outDir": "build"
|
||||
},
|
||||
"exclude": ["node_modules", "build"]
|
||||
"exclude": ["node_modules", "build", "./app-plaid.js"]
|
||||
}
|
||||
|
|
|
@ -17,11 +17,11 @@ function sequential(fn) {
|
|||
sequenceState.running = fn(...args);
|
||||
|
||||
sequenceState.running.then(
|
||||
val => {
|
||||
(val) => {
|
||||
pump();
|
||||
resolve(val);
|
||||
},
|
||||
err => {
|
||||
(err) => {
|
||||
pump();
|
||||
reject(err);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
async function middleware(err, req, res, next) {
|
||||
async function middleware(err, req, res, _next) {
|
||||
console.log('ERROR', err);
|
||||
res.status(500).send({ status: 'error', reason: 'internal-error' });
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
function handleError(func) {
|
||||
return (req, res) => {
|
||||
func(req, res).catch(err => {
|
||||
console.log('Error', req.originalUrl, err);
|
||||
func(req, res).catch((err) => {
|
||||
console.log('Error', req.originalUrl, err);
|
||||
res.status(500);
|
||||
res.send({ status: 'error', reason: 'internal-error' });
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { handleError }
|
||||
module.exports = { handleError };
|
||||
|
|
12
util/paths.js
Normal file
12
util/paths.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
let { join } = require('path');
|
||||
let config = require('../load-config');
|
||||
|
||||
function getPathForUserFile(fileId) {
|
||||
return join(config.userFiles, `file-${fileId}.blob`);
|
||||
}
|
||||
|
||||
function getPathForGroupFile(groupId) {
|
||||
return join(config.userFiles, `group-${groupId}.sqlite`);
|
||||
}
|
||||
|
||||
module.exports = { getPathForUserFile, getPathForGroupFile };
|
Loading…
Reference in a new issue