Compare commits
65 commits
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 | ||
|
c86f1f5546 | ||
|
a09a028dc9 | ||
|
44da71bcdd | ||
|
343ea0c306 | ||
|
a60f22ef5c | ||
|
d614070f44 | ||
|
fd5d81e399 | ||
|
841d3ac115 | ||
|
3df101a91d | ||
|
3124c29052 | ||
|
a4a4eda0eb | ||
|
7d418b91b4 | ||
|
9ef7771adc | ||
|
d0a8b678d3 | ||
|
6d52cc7c73 | ||
|
2acc034fc1 | ||
|
51a2cb91f2 |
26 changed files with 1011 additions and 1290 deletions
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'
|
||||
}
|
||||
};
|
29
.github/workflows/build.yml
vendored
Normal file
29
.github/workflows/build.yml
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
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: 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
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -8,3 +8,4 @@ bin/large-sync-data.txt
|
|||
user-files
|
||||
server-files
|
||||
fly.toml
|
||||
build/
|
||||
|
|
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none"
|
||||
}
|
21
README.md
21
README.md
|
@ -1,11 +1,10 @@
|
|||
|
||||
This is the main project to run [Actual](https://github.com/actualbudget/actual), a local-first personal finance tool. It comes with the latest version of Actual, and a server to persist changes and make data available across all devices.
|
||||
|
||||
Join the [discord](https://discord.gg/pRYNYr4W5A)!
|
||||
|
||||
## Non-technical users
|
||||
|
||||
We are looking into a feature for one-button click click deployment of Actual. This will reduce the friction for people not as comfortable with the command line.
|
||||
We are working on simpler one-button click deployments of Actual. This will reduce the friction for people not as comfortable with the command line. Some non-official options are listed at the bottom.
|
||||
|
||||
## Running
|
||||
|
||||
|
@ -30,11 +29,12 @@ docker build -t actual-server .
|
|||
docker run -p 5006:5006 actual-server
|
||||
```
|
||||
|
||||
|
||||
## Deploying
|
||||
|
||||
You should deploy your server so it's always running. We recommend [fly.io](https://fly.io) which makes it incredibly easy and provides a free plan.
|
||||
|
||||
[fly.io](https://fly.io) allows running the application directly and provides a free tier. You should be comfortable with using the command line to set it up though.
|
||||
|
||||
[Create an account](https://fly.io/app/sign-in). Although you are required to enter payment details, everything we do here will work on the free tier and you won't be charged.
|
||||
|
||||
Next, [install the `flyctl`](https://fly.io/docs/flyctl/installing/) utility. Run `flyctl auth login` to sign into your account.
|
||||
|
@ -45,7 +45,12 @@ Now, run `flyctl launch` from `actual-server`. You should have a running app now
|
|||
|
||||
Whenever you want to update Actual, update the versions of `@actual-app/api` and `@actual-app/web` in `package.json` and run `flyctl deploy`.
|
||||
|
||||
**Note:** if you don't want to use fly, we still provide a `Dockerfile` to build the app so it should work anywhere that can compile a docker image.
|
||||
### Using a custom Docker setup
|
||||
|
||||
Actual is also available as a Docker image ready to be run in your own custom environment.
|
||||
|
||||
- Docker Hub: `jlongster/actual-server`
|
||||
- Github Registry: `ghcr.io/actualbudget/actual-server`
|
||||
|
||||
### Persisting server data
|
||||
|
||||
|
@ -69,6 +74,13 @@ That's it! Actual will automatically check if the `/data` directory exists and u
|
|||
|
||||
_You can also configure the data dir with the `ACTUAL_USER_FILES` environment variable._
|
||||
|
||||
|
||||
### One-click hosting solutions
|
||||
|
||||
These are non-official methods of one-click solutions for running Actual. If you provide a service like this, feel free to open a PR and add it to this list. These run Actual via a Docker image.
|
||||
|
||||
* PikaPods: [Run on PikaPods](https://www.pikapods.com/pods?run=actual)
|
||||
|
||||
## Configuring the server URL
|
||||
|
||||
The Actual app is totally separate from the server. In this project, they happen to both be served by the same server, but the app doesn't know where the server lives.
|
||||
|
@ -76,3 +88,4 @@ The Actual app is totally separate from the server. In this project, they happen
|
|||
The server could live on a completely different domain. You might setup Actual so that the app and server are running in completely separate places.
|
||||
|
||||
Since Actual doesn't know what server to use, the first thing it does is asks you for the server URL. If you are running this project, simply click "Use this domain" and it will automatically fill it in with the current domain. This works because we are serving the app and server in the same place.
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
4
app.js
4
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);
|
||||
});
|
||||
|
||||
|
@ -59,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);
|
||||
});
|
||||
|
|
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
|
@ -0,0 +1,13 @@
|
|||
version: "3"
|
||||
services:
|
||||
actual_server:
|
||||
container_name: actual_server
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5006:5006"
|
||||
volumes:
|
||||
- ./server-files:/app/server-files
|
||||
- ./user-files:/app/user-files
|
||||
restart: unless-stopped
|
|
@ -5,11 +5,13 @@ kill_timeout = 5
|
|||
processes = []
|
||||
|
||||
[env]
|
||||
PORT = "8080"
|
||||
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 => {
|
||||
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