Extract budget file related server handlers from main.ts to server/budgetfiles/app.ts (#4547)

* Extract budget file related server handlers from main.ts to server/budgetfiles/app.ts

* Fix lint error

* Release notes

* Fix typo

* Move backups to budgetfiles folder

* Move backup snapshot

* Fix lint

* Fix lint
This commit is contained in:
Joel Jeremy Marquez
2025-04-08 08:21:35 -07:00
committed by GitHub
parent 0d420ab4d9
commit 490ec22b8a
20 changed files with 772 additions and 723 deletions

View File

@@ -10,7 +10,7 @@ import { View } from '@actual-app/components/view';
import { loadBackup, makeBackup } from 'loot-core/client/budgets/budgetsSlice';
import { type Modal as ModalType } from 'loot-core/client/modals/modalsSlice';
import { send, listen } from 'loot-core/platform/client/fetch';
import { type Backup } from 'loot-core/server/backups';
import { type Backup } from 'loot-core/server/budgetfiles/backups';
import { useMetadataPref } from '../../hooks/useMetadataPref';
import { useDispatch } from '../../redux';

View File

@@ -75,10 +75,6 @@ export function DuplicateFileModal({
await dispatch(
duplicateBudget({
id: 'id' in file ? file.id : undefined,
cloudId:
sync === 'cloudSync' && 'cloudFileId' in file
? file.cloudFileId
: undefined,
oldName: file.name,
newName,
cloudSync: sync === 'cloudSync',

View File

@@ -26,18 +26,20 @@ export function ExportBudget() {
const response = await send('export-budget');
if ('error' in response) {
if ('error' in response && response.error) {
setError(response.error);
setIsLoading(false);
console.log('Export error code:', response.error);
return;
}
window.Actual.saveFile(
response.data,
`${format(new Date(), 'yyyy-MM-dd')}-${budgetName}.zip`,
t('Export budget'),
);
if (response.data) {
window.Actual.saveFile(
response.data,
`${format(new Date(), 'yyyy-MM-dd')}-${budgetName}.zip`,
t('Export budget'),
);
}
setIsLoading(false);
}

View File

@@ -189,7 +189,6 @@ export const duplicateBudget = createAppAsyncThunk(
async (
{
id,
cloudId,
oldName,
newName,
managePage,
@@ -198,6 +197,10 @@ export const duplicateBudget = createAppAsyncThunk(
}: DuplicateBudgetPayload,
{ dispatch },
) => {
if (!id) {
throw new Error('Unable to duplicate a budget that is not local.');
}
try {
await dispatch(
setAppState({
@@ -210,7 +213,6 @@ export const duplicateBudget = createAppAsyncThunk(
await send('duplicate-budget', {
id,
cloudId,
newName,
cloudSync,
open: loadBudget,
@@ -309,14 +311,18 @@ export const downloadBudget = createAppAsyncThunk(
);
const { id, error } = await send('download-budget', {
fileId: cloudFileId,
replace,
cloudFileId,
});
if (error) {
if (error.reason === 'decrypt-failure') {
const opts = {
hasExistingKey: error.meta && error.meta.isMissingKey,
hasExistingKey: Boolean(
error.meta &&
typeof error.meta === 'object' &&
'isMissingKey' in error.meta &&
error.meta.isMissingKey,
),
cloudFileId,
onSuccess: () => {
dispatch(downloadBudget({ cloudFileId, replace }));
@@ -334,8 +340,16 @@ export const downloadBudget = createAppAsyncThunk(
'This file will be replaced. This probably happened because files were manually ' +
'moved around outside of Actual.',
{
id: error.meta.id,
name: error.meta.name,
id:
error.meta &&
typeof error.meta === 'object' &&
'id' in error.meta &&
error.meta.id,
name:
error.meta &&
typeof error.meta === 'object' &&
'name' in error.meta &&
error.meta.name,
},
),
);
@@ -349,15 +363,17 @@ export const downloadBudget = createAppAsyncThunk(
}
return null;
} else {
if (!id) {
throw new Error('No id returned from download.');
}
await Promise.all([
dispatch(loadGlobalPrefs()),
dispatch(loadAllFiles()),
dispatch(loadBudget({ id })),
]);
await dispatch(setAppState({ loadingText: null }));
return id;
}
return id;
},
);

View File

@@ -96,7 +96,7 @@ export function useSchedules({
setError(undefined);
if (!query) {
console.error('No query provided to useSchedules');
// This usually means query is not yet set on this render cycle.
return;
}

View File

@@ -223,7 +223,7 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
// Download the remote file (no need to perform a sync as the file will already be up-to-date)
const result = await handlers['download-budget']({
fileId: remoteBudget.fileId,
cloudFileId: remoteBudget.fileId,
});
if (result.error) {
console.log('Full error details', result.error);

View File

@@ -0,0 +1,678 @@
// @ts-strict-ignore
import * as CRDT from '@actual-app/crdt';
import { createTestBudget } from '../../mocks/budget';
import { captureException, captureBreadcrumb } from '../../platform/exceptions';
import * as asyncStorage from '../../platform/server/asyncStorage';
import * as connection from '../../platform/server/connection';
import * as fs from '../../platform/server/fs';
import { logger } from '../../platform/server/log';
import { Budget } from '../../types/budget';
import { createApp } from '../app';
import * as budget from '../budget/base';
import * as cloudStorage from '../cloud-storage';
import * as db from '../db';
import * as mappings from '../db/mappings';
import { handleBudgetImport, ImportableBudgetType } from '../importers';
import { app as mainApp } from '../main-app';
import { mutator } from '../mutators';
import * as Platform from '../platform';
import * as prefs from '../prefs';
import { getServer } from '../server-config';
import * as sheet from '../sheet';
import { setSyncingMode, initialFullSync, clearFullSyncTimeout } from '../sync';
import * as syncMigrations from '../sync/migrate';
import * as rules from '../transactions/transaction-rules';
import { clearUndo } from '../undo';
import { updateVersion } from '../update';
import {
idFromBudgetName,
uniqueBudgetName,
validateBudgetName,
} from '../util/budget-name';
import {
getAvailableBackups,
makeBackup as _makeBackup,
loadBackup as _loadBackup,
startBackupService,
stopBackupService,
} from './backups';
const DEMO_BUDGET_ID = '_demo-budget';
const TEST_BUDGET_ID = '_test-budget';
export type BudgetFileHandlers = {
'validate-budget-name': typeof handleValidateBudgetName;
'unique-budget-name': typeof handleUniqueBudgetName;
'get-budgets': typeof getBudgets;
'get-remote-files': typeof getRemoteFiles;
'get-user-file-info': typeof getUserFileInfo;
'reset-budget-cache': typeof resetBudgetCache;
'upload-budget': typeof uploadBudget;
'download-budget': typeof downloadBudget;
'sync-budget': typeof syncBudget;
'load-budget': typeof loadBudget;
'create-demo-budget': typeof createDemoBudget;
'close-budget': typeof closeBudget;
'delete-budget': typeof deleteBudget;
'duplicate-budget': typeof duplicateBudget;
'create-budget': typeof createBudget;
'import-budget': typeof importBudget;
'export-budget': typeof exportBudget;
'upload-file-web': typeof uploadFileWeb;
'backups-get': typeof getBackups;
'backup-load': typeof loadBackup;
'backup-make': typeof makeBackup;
'get-last-opened-backup': typeof getLastOpenedBackup;
};
export const app = createApp<BudgetFileHandlers>();
app.method('validate-budget-name', handleValidateBudgetName);
app.method('unique-budget-name', handleUniqueBudgetName);
app.method('get-budgets', getBudgets);
app.method('get-remote-files', getRemoteFiles);
app.method('get-user-file-info', getUserFileInfo);
app.method('reset-budget-cache', mutator(resetBudgetCache));
app.method('upload-budget', uploadBudget);
app.method('download-budget', downloadBudget);
app.method('sync-budget', syncBudget);
app.method('load-budget', loadBudget);
app.method('create-demo-budget', createDemoBudget);
app.method('close-budget', closeBudget);
app.method('delete-budget', deleteBudget);
app.method('duplicate-budget', duplicateBudget);
app.method('create-budget', createBudget);
app.method('import-budget', importBudget);
app.method('export-budget', exportBudget);
app.method('upload-file-web', uploadFileWeb);
app.method('backups-get', getBackups);
app.method('backup-load', loadBackup);
app.method('backup-make', makeBackup);
app.method('get-last-opened-backup', getLastOpenedBackup);
async function handleValidateBudgetName({ name }: { name: string }) {
return validateBudgetName(name);
}
async function handleUniqueBudgetName({ name }: { name: string }) {
return uniqueBudgetName(name);
}
async function getBudgets() {
const paths = await fs.listDir(fs.getDocumentDir());
const budgets: (Budget | null)[] = await Promise.all(
paths.map(async name => {
const prefsPath = fs.join(fs.getDocumentDir(), name, 'metadata.json');
if (await fs.exists(prefsPath)) {
let prefs;
try {
prefs = JSON.parse(await fs.readFile(prefsPath));
} catch (e) {
console.log('Error parsing metadata:', e.stack);
return null;
}
// We treat the directory name as the canonical id so that if
// the user moves it around/renames/etc, nothing breaks. The
// id is stored in prefs just for convenience (and the prefs
// will always update to the latest given id)
if (name !== DEMO_BUDGET_ID) {
return {
id: name,
...(prefs.cloudFileId ? { cloudFileId: prefs.cloudFileId } : {}),
...(prefs.encryptKeyId ? { encryptKeyId: prefs.encryptKeyId } : {}),
...(prefs.groupId ? { groupId: prefs.groupId } : {}),
...(prefs.owner ? { owner: prefs.owner } : {}),
name: prefs.budgetName || '(no name)',
} satisfies Budget;
}
}
return null;
}),
);
return budgets.filter(Boolean) as Budget[];
}
async function getRemoteFiles() {
return cloudStorage.listRemoteFiles();
}
async function getUserFileInfo(fileId: string) {
return cloudStorage.getRemoteFile(fileId);
}
async function resetBudgetCache() {
// Recomputing everything will update the cache
await sheet.loadUserBudgets(db);
sheet.get().recomputeAll();
await sheet.waitOnSpreadsheet();
}
async function uploadBudget({ id }: { id?: Budget['id'] } = {}): Promise<{
error?: { reason: string };
}> {
if (id) {
if (prefs.getPrefs()) {
throw new Error('upload-budget: id given but prefs already loaded');
}
await prefs.loadPrefs(id);
}
try {
await cloudStorage.upload();
} catch (e) {
console.log(e);
if (e.type === 'FileUploadError') {
return { error: e };
}
captureException(e);
return { error: { reason: 'internal' } };
} finally {
if (id) {
prefs.unloadPrefs();
}
}
return {};
}
async function downloadBudget({
cloudFileId,
}: {
cloudFileId: Budget['cloudFileId'];
}): Promise<{ id?: Budget['id']; error?: { reason: string; meta?: unknown } }> {
let result;
try {
result = await cloudStorage.download(cloudFileId);
} catch (e) {
if (e.type === 'FileDownloadError') {
if (e.reason === 'file-exists' && e.meta.id) {
await prefs.loadPrefs(e.meta.id);
const name = prefs.getPrefs().budgetName;
prefs.unloadPrefs();
e.meta = { ...e.meta, name };
}
return { error: e };
} else {
captureException(e);
return { error: { reason: 'internal' } };
}
}
const id = result.id;
await loadBudget({ id });
result = await syncBudget();
if (result.error) {
return result;
}
return { id };
}
// open and sync, but dont close
async function syncBudget() {
setSyncingMode('enabled');
const result = await initialFullSync();
return result;
}
async function loadBudget({ id }: { id: Budget['id'] }) {
const currentPrefs = prefs.getPrefs();
if (currentPrefs) {
if (currentPrefs.id === id) {
// If it's already loaded, do nothing
return {};
} else {
// Otherwise, close the currently loaded budget
await closeBudget();
}
}
const res = await _loadBudget(id);
return res;
}
async function createDemoBudget() {
// Make sure the read only flag isn't leftover (normally it's
// reset when signing in, but you don't have to sign in for the
// demo budget)
await asyncStorage.setItem('readOnly', '');
return createBudget({
budgetName: 'Demo Budget',
testMode: true,
testBudgetId: DEMO_BUDGET_ID,
});
}
async function closeBudget() {
captureBreadcrumb({ message: 'Closing budget' });
// The spreadsheet may be running, wait for it to complete
await sheet.waitOnSpreadsheet();
sheet.unloadSpreadsheet();
clearFullSyncTimeout();
await app.stopServices();
await db.closeDatabase();
try {
await asyncStorage.setItem('lastBudget', '');
} catch (e) {
// This might fail if we are shutting down after failing to load a
// budget. We want to unload whatever has already been loaded but
// be resilient to anything failing
}
prefs.unloadPrefs();
await stopBackupService();
return 'ok';
}
async function deleteBudget({
id,
cloudFileId,
}: {
id?: Budget['id'];
cloudFileId?: Budget['cloudFileId'];
}) {
// If it's a cloud file, you can delete it from the server by
// passing its cloud id
if (cloudFileId) {
await cloudStorage.removeFile(cloudFileId).catch(() => {});
}
// If a local file exists, you can delete it by passing its local id
if (id) {
// opening and then closing the database is a hack to be able to delete
// the budget file if it hasn't been opened yet. This needs a better
// way, but works for now.
try {
await db.openDatabase(id);
await db.closeDatabase();
const budgetDir = fs.getBudgetDir(id);
await fs.removeDirRecursively(budgetDir);
} catch (e) {
return 'fail';
}
}
return 'ok';
}
async function duplicateBudget({
id,
newName,
cloudSync,
open,
}: {
id: Budget['id'];
newName: Budget['name'];
cloudSync: boolean;
open: 'none' | 'original' | 'copy';
}): Promise<Budget['id']> {
const { valid, message } = await validateBudgetName(newName);
if (!valid) throw new Error(message);
const budgetDir = fs.getBudgetDir(id);
const newId = await idFromBudgetName(newName);
// copy metadata from current budget
// replace id with new budget id and budgetName with new budget name
const metadataText = await fs.readFile(fs.join(budgetDir, 'metadata.json'));
const metadata = JSON.parse(metadataText);
metadata.id = newId;
metadata.budgetName = newName;
[
'cloudFileId',
'groupId',
'lastUploaded',
'encryptKeyId',
'lastSyncedTimestamp',
].forEach(item => {
if (metadata[item]) delete metadata[item];
});
try {
const newBudgetDir = fs.getBudgetDir(newId);
await fs.mkdir(newBudgetDir);
// write metadata for new budget
await fs.writeFile(
fs.join(newBudgetDir, 'metadata.json'),
JSON.stringify(metadata),
);
await fs.copyFile(
fs.join(budgetDir, 'db.sqlite'),
fs.join(newBudgetDir, 'db.sqlite'),
);
} catch (error) {
// Clean up any partially created files
try {
const newBudgetDir = fs.getBudgetDir(newId);
if (await fs.exists(newBudgetDir)) {
await fs.removeDirRecursively(newBudgetDir);
}
} catch {} // Ignore cleanup errors
throw new Error(`Failed to duplicate budget file: ${error.message}`);
}
// load in and validate
const { error } = await _loadBudget(newId);
if (error) {
console.log('Error duplicating budget: ' + error);
return error;
}
if (cloudSync) {
try {
await cloudStorage.upload();
} catch (error) {
console.warn('Failed to sync duplicated budget to cloud:', error);
// Ignore any errors uploading. If they are offline they should
// still be able to create files.
}
}
await closeBudget();
if (open === 'original') await _loadBudget(id);
if (open === 'copy') await _loadBudget(newId);
return newId;
}
async function createBudget({
budgetName,
avoidUpload,
testMode,
testBudgetId,
}: {
budgetName?: Budget['name'];
avoidUpload?: boolean;
testMode?: boolean;
testBudgetId?: Budget['name'];
} = {}) {
let id;
if (testMode) {
budgetName = budgetName || 'Test Budget';
id = testBudgetId || TEST_BUDGET_ID;
if (await fs.exists(fs.getBudgetDir(id))) {
await fs.removeDirRecursively(fs.getBudgetDir(id));
}
} else {
// Generate budget name if not given
if (!budgetName) {
budgetName = await uniqueBudgetName();
}
id = await idFromBudgetName(budgetName);
}
const budgetDir = fs.getBudgetDir(id);
await fs.mkdir(budgetDir);
// Create the initial database
await fs.copyFile(fs.bundledDatabasePath, fs.join(budgetDir, 'db.sqlite'));
// Create the initial prefs file
await fs.writeFile(
fs.join(budgetDir, 'metadata.json'),
JSON.stringify(prefs.getDefaultPrefs(id, budgetName)),
);
// Load it in
const { error } = await _loadBudget(id);
if (error) {
console.log('Error creating budget: ' + error);
return { error };
}
if (!avoidUpload && !testMode) {
try {
await cloudStorage.upload();
} catch (e) {
// Ignore any errors uploading. If they are offline they should
// still be able to create files.
}
}
if (testMode) {
await createTestBudget(mainApp.handlers);
}
return {};
}
async function importBudget({
filepath,
type,
}: {
filepath: string;
type: ImportableBudgetType;
}): Promise<{ error?: string }> {
try {
if (!(await fs.exists(filepath))) {
throw new Error(`File not found at the provided path: ${filepath}`);
}
const buffer = Buffer.from(await fs.readFile(filepath, 'binary'));
const results = await handleBudgetImport(type, filepath, buffer);
return results || {};
} catch (err) {
err.message = 'Error importing budget: ' + err.message;
captureException(err);
return { error: 'internal-error' };
}
}
async function exportBudget() {
try {
return {
data: await cloudStorage.exportBuffer(),
};
} catch (err) {
err.message = 'Error exporting budget: ' + err.message;
captureException(err);
return { error: 'internal-error' };
}
}
function onSheetChange({ names }: { names: string[] }) {
const nodes = names.map(name => {
const node = sheet.get()._getNode(name);
return { name: node.name, value: node.value };
});
connection.send('cells-changed', nodes);
}
async function _loadBudget(id: Budget['id']): Promise<{
error?:
| 'budget-not-found'
| 'loading-budget'
| 'out-of-sync-migrations'
| 'out-of-sync-data'
| 'opening-budget';
}> {
let dir: string;
try {
dir = fs.getBudgetDir(id);
} catch (e) {
captureException(
new Error('`getBudgetDir` failed in `loadBudget`: ' + e.message),
);
return { error: 'budget-not-found' };
}
captureBreadcrumb({ message: 'Loading budget ' + dir });
if (!(await fs.exists(dir))) {
captureException(new Error('budget directory does not exist'));
return { error: 'budget-not-found' };
}
try {
await prefs.loadPrefs(id);
await db.openDatabase(id);
} catch (e) {
captureBreadcrumb({ message: 'Error loading budget ' + id });
captureException(e);
await closeBudget();
return { error: 'opening-budget' };
}
// Older versions didn't tag the file with the current user, so do
// so now
if (!prefs.getPrefs().userId) {
const userId = await asyncStorage.getItem('user-token');
await prefs.savePrefs({ userId });
}
try {
await updateVersion();
} catch (e) {
console.warn('Error updating', e);
let result;
if (e.message.includes('out-of-sync-migrations')) {
result = { error: 'out-of-sync-migrations' };
} else if (e.message.includes('out-of-sync-data')) {
result = { error: 'out-of-sync-data' };
} else {
captureException(e);
logger.info('Error updating budget ' + id, e);
console.log('Error updating budget', e);
result = { error: 'loading-budget' };
}
await closeBudget();
return result;
}
await db.loadClock();
if (prefs.getPrefs().resetClock) {
// If we need to generate a fresh clock, we need to generate a new
// client id. This happens when the database is transferred to a
// new device.
//
// TODO: The client id should be stored elsewhere. It shouldn't
// work this way, but it's fine for now.
CRDT.getClock().timestamp.setNode(CRDT.makeClientId());
await db.runQuery(
'INSERT OR REPLACE INTO messages_clock (id, clock) VALUES (1, ?)',
[CRDT.serializeClock(CRDT.getClock())],
);
await prefs.savePrefs({ resetClock: false });
}
if (
!Platform.isWeb &&
!Platform.isMobile &&
process.env.NODE_ENV !== 'test'
) {
await startBackupService(id);
}
try {
await sheet.loadSpreadsheet(db, onSheetChange);
} catch (e) {
captureException(e);
await closeBudget();
return { error: 'opening-budget' };
}
// This is a bit leaky, but we need to set the initial budget type
const { value: budgetType = 'rollover' } =
(await db.first<Pick<db.DbPreference, 'value'>>(
'SELECT value from preferences WHERE id = ?',
['budgetType'],
)) ?? {};
sheet.get().meta().budgetType = budgetType as prefs.BudgetType;
await budget.createAllBudgets();
// Load all the in-memory state
await mappings.loadMappings();
await rules.loadRules();
await syncMigrations.listen();
await mainApp.startServices();
clearUndo();
// Ensure that syncing is enabled
if (process.env.NODE_ENV !== 'test') {
if (id === DEMO_BUDGET_ID) {
setSyncingMode('disabled');
} else {
if (getServer()) {
setSyncingMode('enabled');
} else {
setSyncingMode('disabled');
}
await asyncStorage.setItem('lastBudget', id);
// Only upload periodically on desktop
if (!Platform.isMobile) {
await cloudStorage.possiblyUpload();
}
}
}
app.events.emit('load-budget', { id });
return {};
}
async function uploadFileWeb({
filename,
contents,
}: {
filename: string;
contents: ArrayBuffer;
}) {
if (!Platform.isWeb) {
return null;
}
await fs.writeFile('/uploads/' + filename, contents);
return {};
}
async function getBackups({ id }) {
return getAvailableBackups(id);
}
async function loadBackup({ id, backupId }) {
await _loadBackup(id, backupId);
}
async function makeBackup({ id }) {
await _makeBackup(id);
}
async function getLastOpenedBackup() {
const id = await asyncStorage.getItem('lastBudget');
if (id && id !== '') {
const budgetDir = fs.getBudgetDir(id);
// We never want to give back a budget that does not exist on the
// filesystem anymore, so first check that it exists
if (await fs.exists(budgetDir)) {
return id;
}
}
return null;
}

View File

@@ -2,13 +2,12 @@
import * as dateFns from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
import * as connection from '../platform/server/connection';
import * as fs from '../platform/server/fs';
import * as sqlite from '../platform/server/sqlite';
import * as monthUtils from '../shared/months';
import * as cloudStorage from './cloud-storage';
import * as prefs from './prefs';
import * as connection from '../../platform/server/connection';
import * as fs from '../../platform/server/fs';
import * as sqlite from '../../platform/server/sqlite';
import * as monthUtils from '../../shared/months';
import * as cloudStorage from '../cloud-storage';
import * as prefs from '../prefs';
// A special backup that represents the latest version of the db that
// can be reverted to after loading a backup

View File

@@ -368,7 +368,7 @@ export async function removeFile(fileId) {
});
}
export async function listRemoteFiles(): Promise<RemoteFile[] | null> {
export async function listRemoteFiles(): Promise<RemoteFile[]> {
const userToken = await asyncStorage.getItem('user-token');
if (!userToken) {
return null;
@@ -391,10 +391,12 @@ export async function listRemoteFiles(): Promise<RemoteFile[] | null> {
return null;
}
return res.data.map(file => ({
...file,
hasKey: encryption.hasKey(file.encryptKeyId),
}));
return res.data
.map(file => ({
...file,
hasKey: encryption.hasKey(file.encryptKeyId),
}))
.filter(Boolean);
}
export async function getRemoteFile(
@@ -429,14 +431,14 @@ export async function getRemoteFile(
};
}
export async function download(fileId) {
export async function download(cloudFileId) {
const userToken = await asyncStorage.getItem('user-token');
const syncServer = getServer().SYNC_SERVER;
const userFileFetch = fetch(`${syncServer}/download-user-file`, {
headers: {
'X-ACTUAL-TOKEN': userToken,
'X-ACTUAL-FILE-ID': fileId,
'X-ACTUAL-FILE-ID': cloudFileId,
},
})
.then(checkHTTPStatus)
@@ -454,11 +456,11 @@ export async function download(fileId) {
const userFileInfoFetch = fetchJSON(`${syncServer}/get-user-file-info`, {
headers: {
'X-ACTUAL-TOKEN': userToken,
'X-ACTUAL-FILE-ID': fileId,
'X-ACTUAL-FILE-ID': cloudFileId,
},
}).catch(err => {
console.log('Error fetching file info', err);
throw FileDownloadError('internal', { fileId });
throw FileDownloadError('internal', { fileId: cloudFileId });
});
const [userFileInfoRes, userFileRes] = await Promise.all([
@@ -471,7 +473,7 @@ export async function download(fileId) {
'Could not download file from the server. Are you sure you have the right file ID?',
userFileInfoRes,
);
throw FileDownloadError('internal', { fileId });
throw FileDownloadError('internal', { fileId: cloudFileId });
}
const fileData = userFileInfoRes.data;

View File

@@ -87,7 +87,12 @@ export function APIError(msg: string, meta?: Record<string, any>) {
export function FileDownloadError(
reason: string,
meta?: { fileId?: string; isMissingKey?: boolean },
meta?: {
fileId?: string;
isMissingKey?: boolean;
name?: string;
id?: string;
},
) {
return { type: 'FileDownloadError', reason, meta };
}

View File

@@ -5,7 +5,7 @@ import { importActual } from './actual';
import * as YNAB4 from './ynab4';
import * as YNAB5 from './ynab5';
type ImportableBudgetType = 'ynab4' | 'ynab5' | 'actual';
export type ImportableBudgetType = 'ynab4' | 'ynab5' | 'actual';
type Importer = {
parseFile(buffer: Buffer): unknown;

View File

@@ -2,18 +2,13 @@
import './polyfills';
import * as injectAPI from '@actual-app/api/injected';
import * as CRDT from '@actual-app/crdt';
import { v4 as uuidv4 } from 'uuid';
import { createTestBudget } from '../mocks/budget';
import { captureException, captureBreadcrumb } from '../platform/exceptions';
import * as asyncStorage from '../platform/server/asyncStorage';
import * as connection from '../platform/server/connection';
import * as fs from '../platform/server/fs';
import { logger } from '../platform/server/log';
import * as sqlite from '../platform/server/sqlite';
import { q } from '../shared/query';
import { type Budget } from '../types/budget';
import { Handlers } from '../types/handlers';
import { OpenIdConfig } from '../types/models/openid';
@@ -21,22 +16,12 @@ import { app as accountsApp } from './accounts/app';
import { app as adminApp } from './admin/app';
import { installAPI } from './api';
import { runQuery as aqlQuery } from './aql';
import {
getAvailableBackups,
loadBackup,
makeBackup,
startBackupService,
stopBackupService,
} from './backups';
import { app as budgetApp } from './budget/app';
import * as budget from './budget/base';
import * as cloudStorage from './cloud-storage';
import { app as budgetFilesApp } from './budgetfiles/app';
import { app as dashboardApp } from './dashboard/app';
import * as db from './db';
import * as mappings from './db/mappings';
import * as encryption from './encryption';
import { app as filtersApp } from './filters/app';
import { handleBudgetImport } from './importers';
import { app } from './main-app';
import { mutator, runHandler } from './mutators';
import { app as notesApp } from './notes/app';
@@ -49,41 +34,13 @@ import { app as reportsApp } from './reports/app';
import { app as rulesApp } from './rules/app';
import { app as schedulesApp } from './schedules/app';
import { getServer, isValidBaseURL, setServer } from './server-config';
import * as sheet from './sheet';
import { app as spreadsheetApp } from './spreadsheet/app';
import {
initialFullSync,
fullSync,
setSyncingMode,
makeTestMessage,
clearFullSyncTimeout,
resetSync,
} from './sync';
import { fullSync, setSyncingMode, makeTestMessage, resetSync } from './sync';
import { app as syncApp } from './sync/app';
import * as syncMigrations from './sync/migrate';
import { app as toolsApp } from './tools/app';
import { app as transactionsApp } from './transactions/app';
import * as rules from './transactions/transaction-rules';
import { clearUndo, undo, redo } from './undo';
import { updateVersion } from './update';
import {
uniqueBudgetName,
idFromBudgetName,
validateBudgetName,
} from './util/budget-name';
const DEMO_BUDGET_ID = '_demo-budget';
const TEST_BUDGET_ID = '_test-budget';
// util
function onSheetChange({ names }) {
const nodes = names.map(name => {
const node = sheet.get()._getNode(name);
return { name: node.name, value: node.value };
});
connection.send('cells-changed', nodes);
}
import { undo, redo } from './undo';
// handlers
@@ -437,388 +394,6 @@ handlers['set-server-url'] = async function ({ url, validate = true }) {
return {};
};
handlers['validate-budget-name'] = async function ({ name }) {
return validateBudgetName(name);
};
handlers['unique-budget-name'] = async function ({ name }) {
return uniqueBudgetName(name);
};
handlers['get-budgets'] = async function () {
const paths = await fs.listDir(fs.getDocumentDir());
const budgets = (
await Promise.all(
paths.map(async name => {
const prefsPath = fs.join(fs.getDocumentDir(), name, 'metadata.json');
if (await fs.exists(prefsPath)) {
let prefs;
try {
prefs = JSON.parse(await fs.readFile(prefsPath));
} catch (e) {
console.log('Error parsing metadata:', e.stack);
return;
}
// We treat the directory name as the canonical id so that if
// the user moves it around/renames/etc, nothing breaks. The
// id is stored in prefs just for convenience (and the prefs
// will always update to the latest given id)
if (name !== DEMO_BUDGET_ID) {
return {
id: name,
...(prefs.cloudFileId ? { cloudFileId: prefs.cloudFileId } : {}),
...(prefs.encryptKeyId
? { encryptKeyId: prefs.encryptKeyId }
: {}),
...(prefs.groupId ? { groupId: prefs.groupId } : {}),
...(prefs.owner ? { owner: prefs.owner } : {}),
name: prefs.budgetName || '(no name)',
} satisfies Budget;
}
}
return null;
}),
)
).filter(x => x);
return budgets;
};
handlers['get-remote-files'] = async function () {
return cloudStorage.listRemoteFiles();
};
handlers['get-user-file-info'] = async function (fileId: string) {
return cloudStorage.getRemoteFile(fileId);
};
handlers['reset-budget-cache'] = mutator(async function () {
// Recomputing everything will update the cache
await sheet.loadUserBudgets(db);
sheet.get().recomputeAll();
await sheet.waitOnSpreadsheet();
});
handlers['upload-budget'] = async function ({ id }: { id? } = {}) {
if (id) {
if (prefs.getPrefs()) {
throw new Error('upload-budget: id given but prefs already loaded');
}
await prefs.loadPrefs(id);
}
try {
await cloudStorage.upload();
} catch (e) {
console.log(e);
if (e.type === 'FileUploadError') {
return { error: e };
}
captureException(e);
return { error: { reason: 'internal' } };
} finally {
if (id) {
prefs.unloadPrefs();
}
}
return {};
};
handlers['download-budget'] = async function ({ fileId }) {
let result;
try {
result = await cloudStorage.download(fileId);
} catch (e) {
if (e.type === 'FileDownloadError') {
if (e.reason === 'file-exists' && e.meta.id) {
await prefs.loadPrefs(e.meta.id);
const name = prefs.getPrefs().budgetName;
prefs.unloadPrefs();
e.meta = { ...e.meta, name };
}
return { error: e };
} else {
captureException(e);
return { error: { reason: 'internal' } };
}
}
const id = result.id;
await handlers['load-budget']({ id });
result = await handlers['sync-budget']();
if (result.error) {
return result;
}
return { id };
};
// open and sync, but dont close
handlers['sync-budget'] = async function () {
setSyncingMode('enabled');
const result = await initialFullSync();
return result;
};
handlers['load-budget'] = async function ({ id }) {
const currentPrefs = prefs.getPrefs();
if (currentPrefs) {
if (currentPrefs.id === id) {
// If it's already loaded, do nothing
return {};
} else {
// Otherwise, close the currently loaded budget
await handlers['close-budget']();
}
}
const res = await loadBudget(id);
return res;
};
handlers['create-demo-budget'] = async function () {
// Make sure the read only flag isn't leftover (normally it's
// reset when signing in, but you don't have to sign in for the
// demo budget)
await asyncStorage.setItem('readOnly', '');
return handlers['create-budget']({
budgetName: 'Demo Budget',
testMode: true,
testBudgetId: DEMO_BUDGET_ID,
});
};
handlers['close-budget'] = async function () {
captureBreadcrumb({ message: 'Closing budget' });
// The spreadsheet may be running, wait for it to complete
await sheet.waitOnSpreadsheet();
sheet.unloadSpreadsheet();
clearFullSyncTimeout();
await app.stopServices();
await db.closeDatabase();
try {
await asyncStorage.setItem('lastBudget', '');
} catch (e) {
// This might fail if we are shutting down after failing to load a
// budget. We want to unload whatever has already been loaded but
// be resilient to anything failing
}
prefs.unloadPrefs();
await stopBackupService();
return 'ok';
};
handlers['delete-budget'] = async function ({ id, cloudFileId }) {
// If it's a cloud file, you can delete it from the server by
// passing its cloud id
if (cloudFileId) {
await cloudStorage.removeFile(cloudFileId).catch(() => {});
}
// If a local file exists, you can delete it by passing its local id
if (id) {
// opening and then closing the database is a hack to be able to delete
// the budget file if it hasn't been opened yet. This needs a better
// way, but works for now.
try {
await db.openDatabase(id);
await db.closeDatabase();
const budgetDir = fs.getBudgetDir(id);
await fs.removeDirRecursively(budgetDir);
} catch (e) {
return 'fail';
}
}
return 'ok';
};
handlers['duplicate-budget'] = async function ({
id,
newName,
cloudSync,
open,
}): Promise<string> {
if (!id) throw new Error('Unable to duplicate a budget that is not local.');
const { valid, message } = await validateBudgetName(newName);
if (!valid) throw new Error(message);
const budgetDir = fs.getBudgetDir(id);
const newId = await idFromBudgetName(newName);
// copy metadata from current budget
// replace id with new budget id and budgetName with new budget name
const metadataText = await fs.readFile(fs.join(budgetDir, 'metadata.json'));
const metadata = JSON.parse(metadataText);
metadata.id = newId;
metadata.budgetName = newName;
[
'cloudFileId',
'groupId',
'lastUploaded',
'encryptKeyId',
'lastSyncedTimestamp',
].forEach(item => {
if (metadata[item]) delete metadata[item];
});
try {
const newBudgetDir = fs.getBudgetDir(newId);
await fs.mkdir(newBudgetDir);
// write metadata for new budget
await fs.writeFile(
fs.join(newBudgetDir, 'metadata.json'),
JSON.stringify(metadata),
);
await fs.copyFile(
fs.join(budgetDir, 'db.sqlite'),
fs.join(newBudgetDir, 'db.sqlite'),
);
} catch (error) {
// Clean up any partially created files
try {
const newBudgetDir = fs.getBudgetDir(newId);
if (await fs.exists(newBudgetDir)) {
await fs.removeDirRecursively(newBudgetDir);
}
} catch {} // Ignore cleanup errors
throw new Error(`Failed to duplicate budget file: ${error.message}`);
}
// load in and validate
const { error } = await loadBudget(newId);
if (error) {
console.log('Error duplicating budget: ' + error);
return error;
}
if (cloudSync) {
try {
await cloudStorage.upload();
} catch (error) {
console.warn('Failed to sync duplicated budget to cloud:', error);
// Ignore any errors uploading. If they are offline they should
// still be able to create files.
}
}
handlers['close-budget']();
if (open === 'original') await loadBudget(id);
if (open === 'copy') await loadBudget(newId);
return newId;
};
handlers['create-budget'] = async function ({
budgetName,
avoidUpload,
testMode,
testBudgetId,
}: {
budgetName?;
avoidUpload?;
testMode?;
testBudgetId?;
} = {}) {
let id;
if (testMode) {
budgetName = budgetName || 'Test Budget';
id = testBudgetId || TEST_BUDGET_ID;
if (await fs.exists(fs.getBudgetDir(id))) {
await fs.removeDirRecursively(fs.getBudgetDir(id));
}
} else {
// Generate budget name if not given
if (!budgetName) {
budgetName = await uniqueBudgetName();
}
id = await idFromBudgetName(budgetName);
}
const budgetDir = fs.getBudgetDir(id);
await fs.mkdir(budgetDir);
// Create the initial database
await fs.copyFile(fs.bundledDatabasePath, fs.join(budgetDir, 'db.sqlite'));
// Create the initial prefs file
await fs.writeFile(
fs.join(budgetDir, 'metadata.json'),
JSON.stringify(prefs.getDefaultPrefs(id, budgetName)),
);
// Load it in
const { error } = await loadBudget(id);
if (error) {
console.log('Error creating budget: ' + error);
return { error };
}
if (!avoidUpload && !testMode) {
try {
await cloudStorage.upload();
} catch (e) {
// Ignore any errors uploading. If they are offline they should
// still be able to create files.
}
}
if (testMode) {
await createTestBudget(handlers);
}
return {};
};
handlers['import-budget'] = async function ({ filepath, type }) {
try {
if (!(await fs.exists(filepath))) {
throw new Error(`File not found at the provided path: ${filepath}`);
}
const buffer = Buffer.from(await fs.readFile(filepath, 'binary'));
const results = await handleBudgetImport(type, filepath, buffer);
return results || {};
} catch (err) {
err.message = 'Error importing budget: ' + err.message;
captureException(err);
return { error: 'internal-error' };
}
};
handlers['export-budget'] = async function () {
try {
return {
data: await cloudStorage.exportBuffer(),
};
} catch (err) {
err.message = 'Error exporting budget: ' + err.message;
captureException(err);
return { error: 'internal-error' };
}
};
handlers['enable-openid'] = async function (loginConfig) {
try {
const userToken = await asyncStorage.getItem('user-token');
@@ -924,172 +499,6 @@ handlers['get-openid-config'] = async function ({ password }) {
}
};
async function loadBudget(id: string) {
let dir: string;
try {
dir = fs.getBudgetDir(id);
} catch (e) {
captureException(
new Error('`getBudgetDir` failed in `loadBudget`: ' + e.message),
);
return { error: 'budget-not-found' };
}
captureBreadcrumb({ message: 'Loading budget ' + dir });
if (!(await fs.exists(dir))) {
captureException(new Error('budget directory does not exist'));
return { error: 'budget-not-found' };
}
try {
await prefs.loadPrefs(id);
await db.openDatabase(id);
} catch (e) {
captureBreadcrumb({ message: 'Error loading budget ' + id });
captureException(e);
await handlers['close-budget']();
return { error: 'opening-budget' };
}
// Older versions didn't tag the file with the current user, so do
// so now
if (!prefs.getPrefs().userId) {
const userId = await asyncStorage.getItem('user-token');
prefs.savePrefs({ userId });
}
try {
await updateVersion();
} catch (e) {
console.warn('Error updating', e);
let result;
if (e.message.includes('out-of-sync-migrations')) {
result = { error: 'out-of-sync-migrations' };
} else if (e.message.includes('out-of-sync-data')) {
result = { error: 'out-of-sync-data' };
} else {
captureException(e);
logger.info('Error updating budget ' + id, e);
console.log('Error updating budget', e);
result = { error: 'loading-budget' };
}
await handlers['close-budget']();
return result;
}
await db.loadClock();
if (prefs.getPrefs().resetClock) {
// If we need to generate a fresh clock, we need to generate a new
// client id. This happens when the database is transferred to a
// new device.
//
// TODO: The client id should be stored elsewhere. It shouldn't
// work this way, but it's fine for now.
CRDT.getClock().timestamp.setNode(CRDT.makeClientId());
await db.runQuery(
'INSERT OR REPLACE INTO messages_clock (id, clock) VALUES (1, ?)',
[CRDT.serializeClock(CRDT.getClock())],
);
await prefs.savePrefs({ resetClock: false });
}
if (
!Platform.isWeb &&
!Platform.isMobile &&
process.env.NODE_ENV !== 'test'
) {
await startBackupService(id);
}
try {
await sheet.loadSpreadsheet(db, onSheetChange);
} catch (e) {
captureException(e);
await handlers['close-budget']();
return { error: 'opening-budget' };
}
// This is a bit leaky, but we need to set the initial budget type
const { value: budgetType = 'rollover' } =
(await db.first<Pick<db.DbPreference, 'value'>>(
'SELECT value from preferences WHERE id = ?',
['budgetType'],
)) ?? {};
sheet.get().meta().budgetType = budgetType;
await budget.createAllBudgets();
// Load all the in-memory state
await mappings.loadMappings();
await rules.loadRules();
await syncMigrations.listen();
await app.startServices();
clearUndo();
// Ensure that syncing is enabled
if (process.env.NODE_ENV !== 'test') {
if (id === DEMO_BUDGET_ID) {
setSyncingMode('disabled');
} else {
if (getServer()) {
setSyncingMode('enabled');
} else {
setSyncingMode('disabled');
}
await asyncStorage.setItem('lastBudget', id);
// Only upload periodically on desktop
if (!Platform.isMobile) {
await cloudStorage.possiblyUpload();
}
}
}
app.events.emit('load-budget', { id });
return {};
}
handlers['upload-file-web'] = async function ({ filename, contents }) {
if (!Platform.isWeb) {
return null;
}
await fs.writeFile('/uploads/' + filename, contents);
return {};
};
handlers['backups-get'] = async function ({ id }) {
return getAvailableBackups(id);
};
handlers['backup-load'] = async function ({ id, backupId }) {
await loadBackup(id, backupId);
};
handlers['backup-make'] = async function ({ id }) {
await makeBackup(id);
};
handlers['get-last-opened-backup'] = async function () {
const id = await asyncStorage.getItem('lastBudget');
if (id && id !== '') {
const budgetDir = fs.getBudgetDir(id);
// We never want to give back a budget that does not exist on the
// filesystem anymore, so first check that it exists
if (await fs.exists(budgetDir)) {
return id;
}
}
return null;
};
handlers['app-focused'] = async function () {
if (prefs.getPrefs() && prefs.getPrefs().id) {
// First we sync
@@ -1119,6 +528,7 @@ app.combine(
payeesApp,
spreadsheetApp,
syncApp,
budgetFilesApp,
);
export function getDefaultDocumentDir() {

View File

@@ -3,6 +3,7 @@ import mitt from 'mitt';
import { QueryState } from '../../shared/query';
import { compileQuery, runCompiledQuery, schema, schemaConfig } from '../aql';
import { BudgetType } from '../prefs';
import { Graph } from './graph-data-structure';
import { unresolveName, resolveName } from './util';
@@ -20,7 +21,10 @@ export type Node = {
};
export class Spreadsheet {
_meta;
_meta: {
createdMonths: Set<string>;
budgetType: BudgetType;
};
cacheBarrier;
computeQueue;
dirtyCells;
@@ -44,6 +48,7 @@ export class Spreadsheet {
this.events = mitt();
this._meta = {
createdMonths: new Set(),
budgetType: 'rollover',
};
}

View File

@@ -40,7 +40,15 @@ export function getUploadError({
}
}
export function getDownloadError({ reason, meta, fileName }) {
export function getDownloadError({
reason,
meta,
fileName,
}: {
reason: string;
meta?: unknown;
fileName?: string;
}) {
switch (reason) {
case 'network':
case 'download-failure':
@@ -65,7 +73,10 @@ export function getDownloadError({ reason, meta, fileName }) {
);
default:
const info = meta && meta.fileId ? `, fileId: ${meta.fileId}` : '';
const info =
meta && typeof meta === 'object' && 'fileId' in meta && meta.fileId
? `, fileId: ${meta.fileId}`
: '';
return t(
'Something went wrong trying to download that file, sorry! Visit https://actualbudget.org/contact/ for support. reason: {{reason}}{{info}}',
{ reason, info },

View File

@@ -1,6 +1,7 @@
import type { AccountHandlers } from '../server/accounts/app';
import type { AdminHandlers } from '../server/admin/app';
import type { BudgetHandlers } from '../server/budget/app';
import type { BudgetFileHandlers } from '../server/budgetfiles/app';
import type { DashboardHandlers } from '../server/dashboard/app';
import type { FiltersHandlers } from '../server/filters/app';
import type { NotesHandlers } from '../server/notes/app';
@@ -34,6 +35,7 @@ export interface Handlers
AccountHandlers,
PayeesHandlers,
SpreadsheetHandlers,
SyncHandlers {}
SyncHandlers,
BudgetFileHandlers {}
export type HandlerFunctions = Handlers[keyof Handlers];

View File

@@ -1,4 +1,4 @@
import { type Backup } from '../server/backups';
import { type Backup } from '../server/budgetfiles/backups';
import { type UndoState } from '../server/undo';
type SyncSubtype =

View File

@@ -1,18 +1,12 @@
import { Backup } from '../server/backups';
import { RemoteFile } from '../server/cloud-storage';
import { Message } from '../server/sync';
import { QueryState } from '../shared/query';
import { Budget } from './budget';
import { OpenIdConfig } from './models/openid';
import { EmptyObject } from './util';
export interface ServerHandlers {
undo: () => Promise<void>;
redo: () => Promise<void>;
'get-earliest-transaction': () => Promise<{ date: string }>;
'make-filters-from-conditions': (arg: {
conditions: unknown;
applySpecialCases?: boolean;
@@ -104,83 +98,6 @@ export interface ServerHandlers {
| { messages: Message[] }
>;
'validate-budget-name': (arg: {
name: string;
}) => Promise<{ valid: boolean; message?: string }>;
'unique-budget-name': (arg: { name: string }) => Promise<string>;
'get-budgets': () => Promise<Budget[]>;
'get-remote-files': () => Promise<RemoteFile[]>;
'get-user-file-info': (fileId: string) => Promise<RemoteFile | null>;
'reset-budget-cache': () => Promise<unknown>;
'upload-budget': (arg: { id }) => Promise<{ error?: string }>;
'download-budget': (arg: { fileId; replace? }) => Promise<{ error; id }>;
'sync-budget': () => Promise<{
error?: { message: string; reason: string; meta: unknown };
}>;
'load-budget': (arg: { id: string }) => Promise<{ error }>;
'create-demo-budget': () => Promise<unknown>;
'close-budget': () => Promise<'ok'>;
'delete-budget': (arg: {
id?: string | undefined;
cloudFileId?: string | undefined;
}) => Promise<'ok' | 'fail'>;
/**
* Duplicates a budget file.
* @param {Object} arg - The arguments for duplicating a budget.
* @param {string} [arg.id] - The ID of the local budget to duplicate.
* @param {string} [arg.cloudId] - The ID of the cloud-synced budget to duplicate.
* @param {string} arg.newName - The name for the duplicated budget.
* @param {boolean} [arg.cloudSync] - Whether to sync the duplicated budget to the cloud.
* @returns {Promise<string>} The ID of the newly created budget.
*/
'duplicate-budget': (arg: {
id?: string | undefined;
cloudId?: string | undefined;
newName: string;
cloudSync?: boolean;
open: 'none' | 'original' | 'copy';
}) => Promise<string>;
'create-budget': (arg: {
budgetName?;
avoidUpload?;
testMode?: boolean;
testBudgetId?;
}) => Promise<unknown>;
'import-budget': (arg: {
filepath: string;
type: 'ynab4' | 'ynab5' | 'actual';
}) => Promise<{ error?: string }>;
'export-budget': () => Promise<{ data: Buffer } | { error: string }>;
'upload-file-web': (arg: {
filename: string;
contents: ArrayBuffer;
}) => Promise<EmptyObject | null>;
'backups-get': (arg: { id: string }) => Promise<Backup[]>;
'backup-load': (arg: { id: string; backupId: string }) => Promise<void>;
'backup-make': (arg: { id: string }) => Promise<void>;
'get-last-opened-backup': () => Promise<string | null>;
'app-focused': () => Promise<void>;
'enable-openid': (arg: {

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Extract budget file related server handlers from main.ts to server/budgetfiles/app.ts