Compare commits

...

17 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
3ab1036322 Merge branch 'master' into claude/secure-custom-fonts-evOw0 2026-03-21 20:20:52 +00:00
Matiss Janis Aboltins
fca27bf843 Add global unstubbing in afterEach for embedThemeFonts tests 2026-03-21 20:16:48 +00:00
Matiss Janis Aboltins
28ddeba0fe Implement font size budget enforcement in embedThemeFonts function 2026-03-21 20:15:26 +00:00
Matiss Janis Aboltins
23adf06cb0 Add post-merge hook to auto-install dependencies (#7248)
* [AI] Add post-merge hook to auto-install when yarn.lock changes

Mirrors the existing post-checkout hook behavior: after a git pull or
merge, if yarn.lock changed between ORIG_HEAD and HEAD, automatically
runs yarn install to keep dependencies in sync.

https://claude.ai/code/session_01JHoMhGANLTc1q67s1dUHrt

* Add release notes for PR #7248

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-20 22:59:05 +00:00
Matiss Janis Aboltins
41714740c3 [AI] Fix path traversal, spaces in font URLs, and add embedThemeFonts tests
Reject path-traversal (../) and root-anchored (/) font paths in
embedThemeFonts to prevent URL manipulation. Fix URL regex to handle
quoted filenames with spaces (e.g. "Inter Variable.woff2"). Add unit
tests covering both security validations and normal embedding flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:00:26 +00:00
Matiss Janis Aboltins
f44fb7152f Enhance validation for CSS custom properties in customThemes.ts
Add comprehensive checks in the `validateRootContent` function to ensure CSS custom properties start with '--', contain valid characters, and do not end with a dash. This improves error handling for invalid property names, ensuring better compliance with CSS standards.
2026-03-20 21:42:01 +00:00
Matiss Janis Aboltins
b57ae405a9 Enhance font-family validation to disallow empty values
Update the `validateFontFamilyValue` function to throw an error for empty font-family values, improving security and validation accuracy. Adjust tests to reflect this change, ensuring that empty values are properly handled as invalid.
2026-03-20 21:36:52 +00:00
Matiss Janis Aboltins
ac7222caf2 Update Content Security Policy to include font-src directive
Enhance the Content Security Policy in both the desktop client and sync server to allow font loading from data URIs. This change ensures that custom fonts can be embedded securely while maintaining the existing security measures for other resources.
2026-03-20 21:32:00 +00:00
Matiss Janis Aboltins
a8a2d23e63 [AI] Improve CLI query command with new flags, subcommands, and docs (#7240)
* [AI] Improve CLI query command with new flags, subcommands, and docs

Add --last, --count, --where, --offset, --group-by flags and field:desc
order-by syntax to make the query command more usable for both humans
and AI agents. Add query tables/fields subcommands for schema discovery.
Expand CLI and ActualQL documentation with comprehensive examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Fix parseOrderBy empty field validation and misleading aggregate docs

Validate that order-by field names are non-empty (e.g. ":desc" now throws
a clear error). Move AVAILABLE_TABLES before first use. Update group-by
examples in CLI help text and docs to use --file with proper aggregate
JSON instead of misleading --select with plain field names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-20 19:01:20 +00:00
Matiss Janis Aboltins
8237da8e7b [AI] Simplify @font-face validation to only block external URLs
Remove ~210 lines of overly thorough font validation (MIME type allowlists,
base64 encoding checks, format hint validation, @font-face property allowlists,
font-family name regex) and replace with a single function that enforces the
actual security goal: rejecting non-data: URIs to prevent external resource
loading. Size limits for DoS prevention are preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:06:43 +00:00
Matiss Janis Aboltins
71e3b9edc4 Add custom release notes for upcoming feature: support for custom fonts in themes 2026-03-19 21:55:01 +00:00
Matiss Janis Aboltins
fc8480bde4 Enhance font validation in customThemes.ts 2026-03-19 21:54:23 +00:00
Matiss Janis Aboltins
61636d74b2 [AI] Simplify and improve custom font validation code
Code quality improvements from review:

- Remove dead `declaredFonts` Set (was populated but never read after
  allowlist removal)
- Extract `stripQuotes()` helper to deduplicate quote-stripping logic
  between `validateFontFamilyValue` and `validateFontFaceBlock`
- Replace confusing `const searchFrom = 0` loop with `for (;;)` idiom
  in `extractFontFaceBlocks`
- Use index tracking (`content.substring(start, i)`) instead of
  character-by-character string concatenation in `splitDeclarations`
- Use `splitDeclarations` in `validateRootContent` instead of naive
  `split(';')` for consistency and correctness
- Parallelize font fetches in `embedThemeFonts` with `Promise.all`
  instead of sequential awaits
- Replace byte-by-byte base64 conversion with chunked
  `arrayBufferToBase64()` helper (8KB chunks)
- Reuse indexOf-based @font-face parsing in `embedThemeFonts` instead
  of fragile `[^}]*` regex that can't handle large data URIs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:41:45 +00:00
Claude
2dcbdcfeaf [AI] Remove font-family allowlist and broaden --font-* regex
- Remove SAFE_FONT_FAMILIES allowlist and SAFE_FONT_FAMILIES_LOWER lookup.
  Any font name is now valid in --font-* properties. Referencing a font
  that isn't installed simply triggers the browser's normal fallback — no
  network requests, no security risk. Function calls (url(), expression(),
  etc.) are still blocked.

- Change the --font-* property regex from a specific list
  (family|mono|heading|...) to match all --font-* variables, so theme
  authors can use any --font-prefixed custom property.

https://claude.ai/code/session_01D4ASLpcBCvWF1nzPLz9Tw5
2026-03-19 21:05:59 +00:00
Claude
789dd1c862 [AI] Rename --font-body CSS variable to --font-family
https://claude.ai/code/session_01D4ASLpcBCvWF1nzPLz9Tw5
2026-03-19 20:59:37 +00:00
Claude
4c64b52ea0 [AI] Add @font-face support with data: URI embedding for custom themes
Enable truly custom fonts in themes while maintaining zero runtime network
requests. Theme authors can include font files in their GitHub repos, and
fonts are automatically downloaded and embedded as data: URIs at install
time — the same approach used for theme CSS itself.

Security model:
- @font-face blocks only allow data: URIs (no http/https/relative URLs)
- Font MIME types are validated (font/woff2, font/ttf, etc.)
- Individual font files capped at 2MB, total at 10MB
- @font-face properties are allowlisted (font-family, src, font-weight,
  font-style, font-display, font-stretch, unicode-range only)
- Font-family names from @font-face are available in --font-* variables
- No runtime network requests — all fonts stored locally after install

Key additions:
- extractFontFaceBlocks(): parse @font-face from theme CSS
- validateFontFaceBlock(): validate properties and data: URIs
- splitDeclarations(): semicolon-aware parser that respects data: URIs
- embedThemeFonts(): fetch font files from GitHub, convert to data: URIs
- ThemeInstaller calls embedThemeFonts() during catalog theme installation
- 30+ new test cases for @font-face validation and security edge cases

Example theme CSS with custom fonts:
  @font-face {
    font-family: 'My Font';
    src: url('./MyFont.woff2') format('woff2');
  }
  :root { --font-body: 'My Font', sans-serif; }

https://claude.ai/code/session_01D4ASLpcBCvWF1nzPLz9Tw5
2026-03-19 20:50:48 +00:00
Claude
a16b4107a7 [AI] Add secure custom font support for custom themes
Implement safe font-family references in custom themes via CSS variables
(--font-body, --font-mono, --font-heading, etc.) validated against a
curated allowlist of system-installed and web-safe fonts.

Security approach: Only fonts already present on the user's OS or bundled
with the app are allowed. No @font-face, no url(), no external font
loading — this prevents third-party tracking via font requests while
still enabling meaningful font customization in themes.

Key changes:
- Add SAFE_FONT_FAMILIES allowlist (~80 fonts: generic families, bundled
  fonts, and common system fonts across platforms)
- Add validateFontFamilyValue() for comma-separated font stack validation
- Route --font-{body,mono,heading,family,ui,display,code} properties
  through the font validator instead of the color validator
- Update index.html to use var(--font-body, ...) with current Inter
  Variable stack as fallback
- Add comprehensive tests for valid/invalid font values and security
  edge cases (url injection, javascript:, expression(), etc.)

https://claude.ai/code/session_01D4ASLpcBCvWF1nzPLz9Tw5
2026-03-19 20:35:53 +00:00
14 changed files with 1623 additions and 90 deletions

7
.husky/post-merge Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
# Run yarn install after pulling/merging (if yarn.lock changed)
if git diff --name-only ORIG_HEAD HEAD | grep -q "^yarn.lock$"; then
echo "yarn.lock changed — running yarn install..."
yarn install
fi

View File

@@ -0,0 +1,346 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '../output';
import { parseOrderBy, registerQueryCommand } from './query';
vi.mock('@actual-app/api', () => {
const queryObj = {
select: vi.fn().mockReturnThis(),
filter: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
calculate: vi.fn().mockReturnThis(),
};
return {
q: vi.fn().mockReturnValue(queryObj),
aqlQuery: vi.fn().mockResolvedValue({ data: [] }),
};
});
vi.mock('../connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('../output', () => ({
printOutput: vi.fn(),
}));
function createProgram(): Command {
const program = new Command();
program.option('--format <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerQueryCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
function getQueryObj() {
return vi.mocked(api.q).mock.results[0]?.value;
}
describe('parseOrderBy', () => {
it('parses plain field names', () => {
expect(parseOrderBy('date')).toEqual(['date']);
});
it('parses field:desc', () => {
expect(parseOrderBy('date:desc')).toEqual([{ date: 'desc' }]);
});
it('parses field:asc', () => {
expect(parseOrderBy('amount:asc')).toEqual([{ amount: 'asc' }]);
});
it('parses multiple mixed fields', () => {
expect(parseOrderBy('date:desc,amount:asc,id')).toEqual([
{ date: 'desc' },
{ amount: 'asc' },
'id',
]);
});
it('throws on invalid direction', () => {
expect(() => parseOrderBy('date:backwards')).toThrow(
'Invalid order direction "backwards"',
);
});
it('throws on empty field', () => {
expect(() => parseOrderBy('date,,amount')).toThrow('empty field');
});
});
describe('query commands', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
describe('run', () => {
it('builds a basic query from flags', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--select',
'date,amount',
'--limit',
'5',
]);
expect(api.q).toHaveBeenCalledWith('transactions');
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']);
expect(qObj.limit).toHaveBeenCalledWith(5);
});
it('rejects unknown table name', async () => {
await expect(
run(['query', 'run', '--table', 'nonexistent']),
).rejects.toThrow('Unknown table "nonexistent"');
});
it('parses order-by with desc direction', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--order-by',
'date:desc,amount:asc',
]);
const qObj = getQueryObj();
expect(qObj.orderBy).toHaveBeenCalledWith([
{ date: 'desc' },
{ amount: 'asc' },
]);
});
it('passes --filter as JSON', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--filter',
'{"amount":{"$lt":0}}',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({ amount: { $lt: 0 } });
});
});
describe('--last flag', () => {
it('sets default table, select, orderBy, and limit', async () => {
await run(['query', 'run', '--last', '10']);
expect(api.q).toHaveBeenCalledWith('transactions');
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith([
'date',
'account.name',
'payee.name',
'category.name',
'amount',
'notes',
]);
expect(qObj.orderBy).toHaveBeenCalledWith([{ date: 'desc' }]);
expect(qObj.limit).toHaveBeenCalledWith(10);
});
it('allows explicit --select override', async () => {
await run(['query', 'run', '--last', '5', '--select', 'date,amount']);
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']);
});
it('allows explicit --order-by override', async () => {
await run(['query', 'run', '--last', '5', '--order-by', 'amount:asc']);
const qObj = getQueryObj();
expect(qObj.orderBy).toHaveBeenCalledWith([{ amount: 'asc' }]);
});
it('allows --table transactions explicitly', async () => {
await run(['query', 'run', '--last', '5', '--table', 'transactions']);
expect(api.q).toHaveBeenCalledWith('transactions');
});
it('errors if --table is not transactions', async () => {
await expect(
run(['query', 'run', '--last', '5', '--table', 'accounts']),
).rejects.toThrow('--last implies --table transactions');
});
it('errors if --limit is also set', async () => {
await expect(
run(['query', 'run', '--last', '5', '--limit', '10']),
).rejects.toThrow('--last and --limit are mutually exclusive');
});
});
describe('--count flag', () => {
it('uses calculate with $count', async () => {
vi.mocked(api.aqlQuery).mockResolvedValueOnce({ data: 42 });
await run(['query', 'run', '--table', 'transactions', '--count']);
const qObj = getQueryObj();
expect(qObj.calculate).toHaveBeenCalledWith({ $count: '*' });
expect(printOutput).toHaveBeenCalledWith({ count: 42 }, undefined);
});
it('errors if --select is also set', async () => {
await expect(
run([
'query',
'run',
'--table',
'transactions',
'--count',
'--select',
'date',
]),
).rejects.toThrow('--count and --select are mutually exclusive');
});
});
describe('--where alias', () => {
it('works the same as --filter', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--where',
'{"amount":{"$gt":0}}',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({ amount: { $gt: 0 } });
});
it('errors if both --where and --filter are provided', async () => {
await expect(
run([
'query',
'run',
'--table',
'transactions',
'--where',
'{}',
'--filter',
'{}',
]),
).rejects.toThrow('--where and --filter are mutually exclusive');
});
});
describe('--offset flag', () => {
it('passes offset through to query', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--offset',
'20',
'--limit',
'10',
]);
const qObj = getQueryObj();
expect(qObj.offset).toHaveBeenCalledWith(20);
expect(qObj.limit).toHaveBeenCalledWith(10);
});
});
describe('--group-by flag', () => {
it('passes group-by through to query', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--group-by',
'category.name',
'--select',
'category.name,amount',
]);
const qObj = getQueryObj();
expect(qObj.groupBy).toHaveBeenCalledWith(['category.name']);
});
});
describe('tables subcommand', () => {
it('lists available tables', async () => {
await run(['query', 'tables']);
expect(printOutput).toHaveBeenCalledWith(
expect.arrayContaining([
{ name: 'transactions' },
{ name: 'accounts' },
{ name: 'categories' },
{ name: 'payees' },
]),
undefined,
);
});
});
describe('fields subcommand', () => {
it('lists fields for a known table', async () => {
await run(['query', 'fields', 'accounts']);
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
name: string;
type: string;
}>;
expect(output).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'id', type: 'id' }),
expect.objectContaining({ name: 'name', type: 'string' }),
]),
);
});
it('errors on unknown table', async () => {
await expect(run(['query', 'fields', 'unknown'])).rejects.toThrow(
'Unknown table "unknown"',
);
});
});
});

View File

@@ -10,6 +10,115 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
/**
* Parse order-by strings like "date:desc,amount:asc,id" into
* AQL orderBy format: [{ date: 'desc' }, { amount: 'asc' }, 'id']
*/
export function parseOrderBy(
input: string,
): Array<string | Record<string, string>> {
return input.split(',').map(part => {
const trimmed = part.trim();
if (!trimmed) {
throw new Error('--order-by contains an empty field');
}
const colonIndex = trimmed.indexOf(':');
if (colonIndex === -1) {
return trimmed;
}
const field = trimmed.slice(0, colonIndex).trim();
if (!field) {
throw new Error(
`Invalid order field in "${trimmed}". Field name cannot be empty.`,
);
}
const direction = trimmed.slice(colonIndex + 1);
if (direction !== 'asc' && direction !== 'desc') {
throw new Error(
`Invalid order direction "${direction}" for field "${field}". Expected "asc" or "desc".`,
);
}
return { [field]: direction };
});
}
// TODO: Import schema from API once it exposes table/field metadata
const TABLE_SCHEMA: Record<
string,
Record<string, { type: string; ref?: string }>
> = {
transactions: {
id: { type: 'id' },
account: { type: 'id', ref: 'accounts' },
date: { type: 'date' },
amount: { type: 'integer' },
payee: { type: 'id', ref: 'payees' },
category: { type: 'id', ref: 'categories' },
notes: { type: 'string' },
imported_id: { type: 'string' },
transfer_id: { type: 'id' },
cleared: { type: 'boolean' },
reconciled: { type: 'boolean' },
starting_balance_flag: { type: 'boolean' },
imported_payee: { type: 'string' },
is_parent: { type: 'boolean' },
is_child: { type: 'boolean' },
parent_id: { type: 'id' },
sort_order: { type: 'float' },
schedule: { type: 'id', ref: 'schedules' },
'account.name': { type: 'string', ref: 'accounts' },
'payee.name': { type: 'string', ref: 'payees' },
'category.name': { type: 'string', ref: 'categories' },
'category.group.name': { type: 'string', ref: 'category_groups' },
},
accounts: {
id: { type: 'id' },
name: { type: 'string' },
offbudget: { type: 'boolean' },
closed: { type: 'boolean' },
sort_order: { type: 'float' },
},
categories: {
id: { type: 'id' },
name: { type: 'string' },
is_income: { type: 'boolean' },
group_id: { type: 'id', ref: 'category_groups' },
sort_order: { type: 'float' },
hidden: { type: 'boolean' },
'group.name': { type: 'string', ref: 'category_groups' },
},
payees: {
id: { type: 'id' },
name: { type: 'string' },
transfer_acct: { type: 'id', ref: 'accounts' },
},
rules: {
id: { type: 'id' },
stage: { type: 'string' },
conditions_op: { type: 'string' },
conditions: { type: 'json' },
actions: { type: 'json' },
},
schedules: {
id: { type: 'id' },
name: { type: 'string' },
rule: { type: 'id', ref: 'rules' },
next_date: { type: 'date' },
completed: { type: 'boolean' },
},
};
const AVAILABLE_TABLES = Object.keys(TABLE_SCHEMA).join(', ');
const LAST_DEFAULT_SELECT = [
'date',
'account.name',
'payee.name',
'category.name',
'amount',
'notes',
];
function buildQueryFromFile( function buildQueryFromFile(
parsed: Record<string, unknown>, parsed: Record<string, unknown>,
fallbackTable: string | undefined, fallbackTable: string | undefined,
@@ -27,34 +136,125 @@ function buildQueryFromFile(
queryObj = queryObj.orderBy(parsed.orderBy); queryObj = queryObj.orderBy(parsed.orderBy);
} }
if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit); if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit);
if (typeof parsed.offset === 'number') {
queryObj = queryObj.offset(parsed.offset);
}
if (Array.isArray(parsed.groupBy)) {
queryObj = queryObj.groupBy(parsed.groupBy);
}
return queryObj; return queryObj;
} }
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) { function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
if (!cmdOpts.table) { const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined;
throw new Error('--table is required (or use --file)');
}
let queryObj = api.q(cmdOpts.table);
if (cmdOpts.select) { if (last !== undefined) {
if (cmdOpts.table && cmdOpts.table !== 'transactions') {
throw new Error(
'--last implies --table transactions. Cannot use with --table ' +
cmdOpts.table,
);
}
if (cmdOpts.limit) {
throw new Error('--last and --limit are mutually exclusive');
}
}
const table =
cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined);
if (!table) {
throw new Error('--table is required (or use --file or --last)');
}
if (!(table in TABLE_SCHEMA)) {
throw new Error(
`Unknown table "${table}". Available tables: ${AVAILABLE_TABLES}`,
);
}
if (cmdOpts.where && cmdOpts.filter) {
throw new Error('--where and --filter are mutually exclusive');
}
if (cmdOpts.count && cmdOpts.select) {
throw new Error('--count and --select are mutually exclusive');
}
let queryObj = api.q(table);
if (cmdOpts.count) {
queryObj = queryObj.calculate({ $count: '*' });
} else if (cmdOpts.select) {
queryObj = queryObj.select(cmdOpts.select.split(',')); queryObj = queryObj.select(cmdOpts.select.split(','));
} else if (last !== undefined) {
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
} }
if (cmdOpts.filter) { const filterStr = cmdOpts.filter ?? cmdOpts.where;
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter)); if (filterStr) {
queryObj = queryObj.filter(JSON.parse(filterStr));
} }
if (cmdOpts.orderBy) { const orderByStr =
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(',')); cmdOpts.orderBy ??
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
if (orderByStr) {
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
} }
if (cmdOpts.limit) { const limitVal =
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit')); last ??
(cmdOpts.limit ? parseIntFlag(cmdOpts.limit, '--limit') : undefined);
if (limitVal !== undefined) {
queryObj = queryObj.limit(limitVal);
}
if (cmdOpts.offset) {
queryObj = queryObj.offset(parseIntFlag(cmdOpts.offset, '--offset'));
}
if (cmdOpts.groupBy) {
queryObj = queryObj.groupBy(cmdOpts.groupBy.split(','));
} }
return queryObj; return queryObj;
} }
const RUN_EXAMPLES = `
Examples:
# Show last 5 transactions (shortcut)
actual query run --last 5
# Transactions ordered by date descending
actual query run --table transactions --select "date,amount,payee.name" --order-by "date:desc" --limit 10
# Filter with JSON (negative amounts = expenses)
actual query run --table transactions --filter '{"amount":{"$lt":0}}' --limit 5
# Count transactions
actual query run --table transactions --count
# Group by category (use --file for aggregate expressions)
echo '{"table":"transactions","groupBy":["category.name"],"select":["category.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
# Pagination
actual query run --table transactions --order-by "date:desc" --limit 10 --offset 20
# Use --where (alias for --filter)
actual query run --table transactions --where '{"payee.name":"Grocery Store"}' --limit 5
# Read query from a JSON file
actual query run --file query.json
# Pipe query from stdin
echo '{"table":"transactions","limit":5}' | actual query run --file -
Available tables: ${AVAILABLE_TABLES}
Use "actual query tables" and "actual query fields <table>" for schema info.
Common filter operators: $eq, $ne, $lt, $lte, $gt, $gte, $like, $and, $or
See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/`;
export function registerQueryCommand(program: Command) { export function registerQueryCommand(program: Command) {
const query = program const query = program
.command('query') .command('query')
@@ -65,16 +265,34 @@ export function registerQueryCommand(program: Command) {
.description('Execute an AQL query') .description('Execute an AQL query')
.option( .option(
'--table <table>', '--table <table>',
'Table to query (transactions, accounts, categories, payees)', 'Table to query (use "actual query tables" to list available tables)',
) )
.option('--select <fields>', 'Comma-separated fields to select') .option('--select <fields>', 'Comma-separated fields to select')
.option('--filter <json>', 'Filter expression as JSON') .option('--filter <json>', 'Filter as JSON (e.g. \'{"amount":{"$lt":0}}\')')
.option('--order-by <fields>', 'Comma-separated fields to order by') .option(
'--where <json>',
'Alias for --filter (cannot be used together with --filter)',
)
.option(
'--order-by <fields>',
'Fields with optional direction: field1:desc,field2 (default: asc)',
)
.option('--limit <n>', 'Limit number of results') .option('--limit <n>', 'Limit number of results')
.option('--offset <n>', 'Skip first N results (for pagination)')
.option(
'--last <n>',
'Show last N transactions (implies --table transactions, --order-by date:desc)',
)
.option('--count', 'Count matching rows instead of returning them')
.option(
'--group-by <fields>',
'Comma-separated fields to group by (use with aggregate selects)',
)
.option( .option(
'--file <path>', '--file <path>',
'Read full query object from JSON file (use - for stdin)', 'Read full query object from JSON file (use - for stdin)',
) )
.addHelpText('after', RUN_EXAMPLES)
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection(opts, async () => { await withConnection(opts, async () => {
@@ -87,7 +305,40 @@ export function registerQueryCommand(program: Command) {
: buildQueryFromFlags(cmdOpts); : buildQueryFromFlags(cmdOpts);
const result = await api.aqlQuery(queryObj); const result = await api.aqlQuery(queryObj);
printOutput(result, opts.format);
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);
} else {
printOutput(result, opts.format);
}
}); });
}); });
query
.command('tables')
.description('List available tables for querying')
.action(() => {
const opts = program.opts();
const tables = Object.keys(TABLE_SCHEMA).map(name => ({ name }));
printOutput(tables, opts.format);
});
query
.command('fields <table>')
.description('List fields for a given table')
.action((table: string) => {
const opts = program.opts();
const schema = TABLE_SCHEMA[table];
if (!schema) {
throw new Error(
`Unknown table "${table}". Available tables: ${Object.keys(TABLE_SCHEMA).join(', ')}`,
);
}
const fields = Object.entries(schema).map(([name, info]) => ({
name,
type: info.type,
...(info.ref ? { ref: info.ref } : {}),
}));
printOutput(fields, opts.format);
});
} }

View File

@@ -31,7 +31,8 @@
body, body,
button, button,
input { input {
font-family: font-family: var(
--font-family,
'Inter Variable', 'Inter Variable',
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
@@ -45,7 +46,8 @@
'Helvetica Neue', 'Helvetica Neue',
'Helvetica', 'Helvetica',
'Arial', 'Arial',
sans-serif; sans-serif
);
} }
a { a {
@@ -67,7 +69,8 @@
input, input,
textarea { textarea {
font-size: 1em; font-size: 1em;
font-family: font-family: var(
--font-family,
'Inter Variable', 'Inter Variable',
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
@@ -81,7 +84,8 @@
'Helvetica Neue', 'Helvetica Neue',
'Helvetica', 'Helvetica',
'Arial', 'Arial',
sans-serif; sans-serif
);
font-feature-settings: 'ss01', 'ss04'; font-feature-settings: 'ss01', 'ss04';
} }

View File

@@ -1,10 +1,10 @@
/* /*
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp Cross-Origin-Embedder-Policy: require-corp
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:; Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;
/kcab/* /kcab/*
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:; Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;
/*.wasm /*.wasm
Content-Type: application/wasm Content-Type: application/wasm

View File

@@ -17,6 +17,7 @@ import { Link } from '@desktop-client/components/common/Link';
import { FixedSizeList } from '@desktop-client/components/FixedSizeList'; import { FixedSizeList } from '@desktop-client/components/FixedSizeList';
import { useThemeCatalog } from '@desktop-client/hooks/useThemeCatalog'; import { useThemeCatalog } from '@desktop-client/hooks/useThemeCatalog';
import { import {
embedThemeFonts,
extractRepoOwner, extractRepoOwner,
fetchThemeCss, fetchThemeCss,
generateThemeId, generateThemeId,
@@ -166,8 +167,12 @@ export function ThemeInstaller({
setSelectedCatalogTheme(theme); setSelectedCatalogTheme(theme);
const normalizedRepo = normalizeGitHubRepo(theme.repo); const normalizedRepo = normalizeGitHubRepo(theme.repo);
// Fetch CSS and embed any referenced font files as data: URIs
const cssWithFonts = fetchThemeCss(theme.repo).then(css =>
embedThemeFonts(css, theme.repo),
);
await installTheme({ await installTheme({
css: fetchThemeCss(theme.repo), css: cssWithFonts,
name: theme.name, name: theme.name,
repo: normalizedRepo, repo: normalizedRepo,
id: generateThemeId(normalizedRepo), id: generateThemeId(normalizedRepo),

View File

@@ -1,9 +1,23 @@
// oxlint-disable eslint/no-script-url // oxlint-disable eslint/no-script-url
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseInstalledTheme, validateThemeCss } from './customThemes'; import {
embedThemeFonts,
MAX_FONT_FILE_SIZE,
parseInstalledTheme,
validateThemeCss,
} from './customThemes';
import type { InstalledTheme } from './customThemes'; import type { InstalledTheme } from './customThemes';
// Small valid woff2 data URI for testing (actual content doesn't matter for validation)
const TINY_WOFF2_BASE64 = 'AAAAAAAAAA==';
const TINY_WOFF2_DATA_URI = `data:font/woff2;base64,${TINY_WOFF2_BASE64}`;
const FONT_FACE_BLOCK = `@font-face {
font-family: 'Test Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
font-display: swap;
}`;
describe('validateThemeCss', () => { describe('validateThemeCss', () => {
describe('valid CSS', () => { describe('valid CSS', () => {
it('should accept valid :root with CSS variables', () => { it('should accept valid :root with CSS variables', () => {
@@ -74,7 +88,7 @@ describe('validateThemeCss', () => {
}, },
])('should reject $description', ({ css }) => { ])('should reject $description', ({ css }) => {
expect(() => validateThemeCss(css)).toThrow( expect(() => validateThemeCss(css)).toThrow(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
); );
}); });
}); });
@@ -90,7 +104,7 @@ describe('validateThemeCss', () => {
color: red; color: red;
}`, }`,
expectedError: expectedError:
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
}, },
{ {
description: 'multiple selectors', description: 'multiple selectors',
@@ -101,7 +115,7 @@ describe('validateThemeCss', () => {
--color-primary: #ffffff; --color-primary: #ffffff;
}`, }`,
expectedError: expectedError:
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
}, },
{ {
description: 'media queries', description: 'media queries',
@@ -114,7 +128,7 @@ describe('validateThemeCss', () => {
} }
}`, }`,
expectedError: expectedError:
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
}, },
{ {
description: 'custom selector before :root', description: 'custom selector before :root',
@@ -125,7 +139,7 @@ describe('validateThemeCss', () => {
--color-primary: #007bff; --color-primary: #007bff;
}`, }`,
expectedError: expectedError:
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
}, },
])('should reject CSS with $description', ({ css, expectedError }) => { ])('should reject CSS with $description', ({ css, expectedError }) => {
expect(() => validateThemeCss(css)).toThrow(expectedError); expect(() => validateThemeCss(css)).toThrow(expectedError);
@@ -271,7 +285,7 @@ describe('validateThemeCss', () => {
}, },
])('should reject $description', ({ css }) => { ])('should reject $description', ({ css }) => {
expect(() => validateThemeCss(css)).toThrow( expect(() => validateThemeCss(css)).toThrow(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
); );
}); });
}); });
@@ -779,12 +793,6 @@ describe('validateThemeCss', () => {
--spacing: 10px 20px; --spacing: 10px 20px;
}`, }`,
}, },
{
description: 'value with comma-separated values',
css: `:root {
--font-family: Arial, sans-serif;
}`,
},
{ {
description: 'property name with invalid characters', description: 'property name with invalid characters',
css: `:root { css: `:root {
@@ -868,6 +876,337 @@ describe('validateThemeCss', () => {
}); });
}); });
describe('validateThemeCss - font properties (--font-*)', () => {
describe('valid font-family values', () => {
it.each([
{
description: 'single generic family',
css: `:root { --font-family: sans-serif; }`,
},
{
description: 'single generic family (serif)',
css: `:root { --font-family: serif; }`,
},
{
description: 'single generic family (monospace)',
css: `:root { --font-family: monospace; }`,
},
{
description: 'system-ui keyword',
css: `:root { --font-family: system-ui; }`,
},
{
description: 'bundled font (Inter Variable)',
css: `:root { --font-family: Inter Variable; }`,
},
{
description: 'quoted bundled font',
css: `:root { --font-family: 'Inter Variable'; }`,
},
{
description: 'double-quoted bundled font',
css: `:root { --font-family: "Inter Variable"; }`,
},
{
description: 'web-safe font (Georgia)',
css: `:root { --font-family: Georgia; }`,
},
{
description: 'web-safe font (Times New Roman) quoted',
css: `:root { --font-family: 'Times New Roman'; }`,
},
{
description: 'comma-separated font stack',
css: `:root { --font-family: Georgia, serif; }`,
},
{
description: 'full font stack with multiple fonts',
css: `:root { --font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; }`,
},
{
description: 'monospace font stack',
css: `:root { --font-mono: 'Fira Code', Consolas, Monaco, monospace; }`,
},
{
description: 'case-insensitive matching (arial)',
css: `:root { --font-family: arial; }`,
},
{
description: 'case-insensitive matching (GEORGIA)',
css: `:root { --font-family: GEORGIA; }`,
},
{
description: 'macOS system font',
css: `:root { --font-family: 'SF Pro', -apple-system, sans-serif; }`,
},
{
description: 'mixed with color variables',
css: `:root {
--color-primary: #007bff;
--font-family: Georgia, serif;
--color-secondary: #6c757d;
}`,
},
{
description: '--font-mono property',
css: `:root { --font-mono: 'JetBrains Mono', 'Fira Code', monospace; }`,
},
{
description: '--font-heading property',
css: `:root { --font-heading: Palatino, 'Book Antiqua', serif; }`,
},
])('should accept CSS with $description', ({ css }) => {
expect(() => validateThemeCss(css)).not.toThrow();
});
});
describe('invalid font-family values - security', () => {
it.each([
{
description: 'empty value',
css: `:root { --font-family: ; }`,
expectedPattern: /value must not be empty/,
},
{
description: 'url() function in font value',
css: `:root { --font-family: url('https://evil.com/font.woff2'); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'url() with data: URI',
css: `:root { --font-family: url(data:font/woff2;base64,abc123); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'expression() in font value',
css: `:root { --font-family: expression(alert(1)); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'empty font name between commas',
css: `:root { --font-family: Arial, , sans-serif; }`,
expectedPattern: /empty font name/,
},
{
description: 'Google Fonts URL attempt',
css: `:root { --font-family: url(https://fonts.googleapis.com/css2?family=Roboto); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'local() function',
css: `:root { --font-family: local(Arial); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'format() function',
css: `:root { --font-family: format('woff2'); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'rgb() function in font property',
css: `:root { --font-family: rgb(0, 0, 0); }`,
expectedPattern: /function calls are not allowed/,
},
])('should reject CSS with $description', ({ css, expectedPattern }) => {
expect(() => validateThemeCss(css)).toThrow(expectedPattern);
});
});
describe('any font name is valid (no allowlist)', () => {
it.each([
{
description: 'Comic Sans MS',
css: `:root { --font-family: 'Comic Sans MS'; }`,
},
{
description: 'custom font name',
css: `:root { --font-family: 'My Custom Font', sans-serif; }`,
},
{
description: 'arbitrary string',
css: `:root { --font-family: something-random; }`,
},
{ description: 'Papyrus', css: `:root { --font-family: Papyrus; }` },
])('should accept $description as a font name', ({ css }) => {
expect(() => validateThemeCss(css)).not.toThrow();
});
});
});
describe('validateThemeCss - @font-face blocks', () => {
describe('valid @font-face with data: URIs', () => {
it('should accept @font-face with data: URI and :root', () => {
const css = `${FONT_FACE_BLOCK}
:root { --font-family: 'Test Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept multiple @font-face blocks', () => {
const css = `@font-face {
font-family: 'Test Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Test Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root { --font-family: 'Test Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face with font/woff MIME type', () => {
const css = `@font-face {
font-family: 'Woff Font';
src: url('data:font/woff;base64,${TINY_WOFF2_BASE64}') format('woff');
}
:root { --font-family: 'Woff Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face with font/ttf MIME type', () => {
const css = `@font-face {
font-family: 'TTF Font';
src: url('data:font/ttf;base64,${TINY_WOFF2_BASE64}') format('truetype');
}
:root { --font-family: 'TTF Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face with application/font-woff2 MIME type', () => {
const css = `@font-face {
font-family: 'App Font';
src: url('data:application/font-woff2;base64,${TINY_WOFF2_BASE64}') format('woff2');
}
:root { --font-family: 'App Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face with font-stretch', () => {
const css = `@font-face {
font-family: 'Stretch Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
font-stretch: condensed;
}
:root { --font-family: 'Stretch Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face with unicode-range', () => {
const css = `@font-face {
font-family: 'Unicode Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
unicode-range: U+0000-00FF;
}
:root { --font-family: 'Unicode Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should allow custom font name in --font-family after @font-face declaration', () => {
const css = `@font-face {
font-family: 'My Custom Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
}
:root { --font-family: 'My Custom Font', Georgia, serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face alongside color variables', () => {
const css = `${FONT_FACE_BLOCK}
:root {
--color-primary: #007bff;
--font-family: 'Test Font', sans-serif;
--color-secondary: #6c757d;
}`;
expect(() => validateThemeCss(css)).not.toThrow();
});
});
describe('invalid @font-face - security', () => {
it('should reject @font-face with remote HTTP URL', () => {
const css = `@font-face {
font-family: 'Bad Font';
src: url('https://evil.com/font.woff2') format('woff2');
}
:root { --font-family: 'Bad Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/data: URIs/);
});
it('should reject @font-face with remote HTTPS URL', () => {
const css = `@font-face {
font-family: 'Bad Font';
src: url('https://fonts.example.com/custom.woff2') format('woff2');
}
:root { --font-family: 'Bad Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/data: URIs/);
});
it('should reject @font-face with relative URL (not embedded)', () => {
const css = `@font-face {
font-family: 'Bad Font';
src: url('./fonts/custom.woff2') format('woff2');
}
:root { --font-family: 'Bad Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/data: URIs/);
});
it('should reject @font-face with javascript: protocol', () => {
const css = `@font-face {
font-family: 'Bad Font';
src: url('javascript:alert(1)');
}
:root { --font-family: 'Bad Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/data: URIs/);
});
it('should accept any font name in --font-family (no allowlist)', () => {
const css = `@font-face {
font-family: 'Declared Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
}
:root { --font-family: 'Undeclared Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should reject oversized font data', () => {
// Create a base64 string that would decode to > MAX_FONT_FILE_SIZE
const oversizedBase64 = 'A'.repeat(
Math.ceil((MAX_FONT_FILE_SIZE * 4) / 3) + 100,
);
const css = `@font-face {
font-family: 'Big Font';
src: url('data:font/woff2;base64,${oversizedBase64}') format('woff2');
}
:root { --font-family: 'Big Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/maximum size/);
});
});
describe('CSS without @font-face still works', () => {
it('should accept plain :root without @font-face', () => {
const css = `:root { --color-primary: #007bff; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should reject other at-rules (not @font-face)', () => {
const css = `@import url('other.css');
:root { --color-primary: #007bff; }`;
expect(() => validateThemeCss(css)).toThrow();
});
it('should reject @media outside :root', () => {
const css = `@media (max-width: 600px) { :root { --color-primary: #ff0000; } }
:root { --color-primary: #007bff; }`;
expect(() => validateThemeCss(css)).toThrow();
});
});
});
describe('parseInstalledTheme', () => { describe('parseInstalledTheme', () => {
describe('valid theme JSON', () => { describe('valid theme JSON', () => {
it('should parse valid theme with all required fields', () => { it('should parse valid theme with all required fields', () => {
@@ -1133,3 +1472,123 @@ describe('parseInstalledTheme', () => {
}); });
}); });
}); });
describe('embedThemeFonts', () => {
const mockFetch = (
responseBody: ArrayBuffer,
ok = true,
status = 200,
): typeof globalThis.fetch =>
vi.fn().mockResolvedValue({
ok,
status,
statusText: ok ? 'OK' : 'Not Found',
arrayBuffer: () => Promise.resolve(responseBody),
} as Partial<Response>);
const tinyBuffer = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]).buffer;
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('should rewrite url() references to data URIs', async () => {
vi.stubGlobal('fetch', mockFetch(tinyBuffer));
const css = `@font-face {
font-family: 'Test';
src: url('fonts/test.woff2') format('woff2');
}
:root { --color-primary: #007bff; }`;
const result = await embedThemeFonts(css, 'owner/repo');
expect(result).toContain('data:font/woff2;base64,');
expect(result).not.toContain('fonts/test.woff2');
expect(result).toContain(':root');
});
it('should handle quoted filenames with spaces', async () => {
vi.stubGlobal('fetch', mockFetch(tinyBuffer));
const css = `@font-face {
font-family: 'Inter';
src: url("Inter Variable.woff2") format('woff2');
}
:root { --color-primary: #007bff; }`;
const result = await embedThemeFonts(css, 'owner/repo');
expect(result).toContain('data:font/woff2;base64,');
expect(result).not.toContain('Inter Variable.woff2');
});
it('should reject path traversal with ".."', async () => {
const css = `@font-face {
font-family: 'Evil';
src: url('../escape/font.woff2') format('woff2');
}
:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'is not allowed',
);
});
it('should reject root-anchored paths', async () => {
const css = `@font-face {
font-family: 'Evil';
src: url('/etc/passwd') format('woff2');
}
:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'is not allowed',
);
});
it('should reject oversized font files', async () => {
const oversized = new ArrayBuffer(MAX_FONT_FILE_SIZE + 1);
vi.stubGlobal('fetch', mockFetch(oversized));
const css = `@font-face {
font-family: 'Big';
src: url('big.woff2') format('woff2');
}
:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'exceeds maximum size',
);
});
it('should reject when total font size exceeds budget', async () => {
// Each font is under the per-file limit but together they exceed the total
// Use MAX_FONT_FILE_SIZE (2MB) per font, need 6 to exceed 10MB total
const bigBuffer = new ArrayBuffer(MAX_FONT_FILE_SIZE);
vi.stubGlobal('fetch', mockFetch(bigBuffer));
const fontBlocks = Array.from(
{ length: 6 },
(_, i) => `@font-face {
font-family: 'Font${i}';
src: url('font${i}.woff2') format('woff2');
}`,
).join('\n');
const css = `${fontBlocks}\n:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'Total embedded font data exceeds maximum',
);
});
it('should return CSS unchanged when no url() refs exist', async () => {
const css = `@font-face {
font-family: 'Test';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
}
:root { --color-primary: #007bff; }`;
const result = await embedThemeFonts(css, 'owner/repo');
expect(result).toBe(css);
});
});

View File

@@ -79,6 +79,63 @@ export async function fetchDirectCss(url: string): Promise<string> {
return response.text(); return response.text();
} }
/** Strip surrounding single or double quotes from a string. */
function stripQuotes(s: string): string {
const t = s.trim();
if (
(t.startsWith("'") && t.endsWith("'")) ||
(t.startsWith('"') && t.endsWith('"'))
) {
return t.slice(1, -1).trim();
}
return t;
}
/**
* Validate a font-family value for a --font-* CSS variable.
*
* Any font name is allowed — referencing a font the user doesn't have
* installed simply triggers the browser's normal fallback behaviour
* (no network requests). The only things we block are function calls
* (url(), expression(), etc.) because those could load external resources
* or execute expressions.
*
* Quoted or unquoted font names are both accepted.
*
* Examples of accepted values:
* Georgia, serif
* 'Fira Code', monospace
* "My Theme Font", sans-serif
*/
function validateFontFamilyValue(value: string, property: string): void {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(
`Invalid font-family value for "${property}": value must not be empty.`,
);
}
// Split on commas, then validate each font name
const families = trimmed.split(',');
for (const raw of families) {
const name = stripQuotes(raw);
if (!name) {
throw new Error(
`Invalid font-family value for "${property}": empty font name in comma-separated list.`,
);
}
// Reject anything that looks like a function call (url(), expression(), etc.)
if (/\(/.test(name)) {
throw new Error(
`Invalid font-family value for "${property}": function calls are not allowed. Only font names are permitted.`,
);
}
}
}
/** Only var(--custom-property-name) is allowed; no fallbacks. Variable name: -- then [a-zA-Z0-9_-]+ (no trailing dash). */ /** Only var(--custom-property-name) is allowed; no fallbacks. Variable name: -- then [a-zA-Z0-9_-]+ (no trailing dash). */
const VAR_ONLY_PATTERN = /^var\s*\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/i; const VAR_ONLY_PATTERN = /^var\s*\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/i;
@@ -92,8 +149,15 @@ function isValidSimpleVarValue(value: string): boolean {
/** /**
* Validate that a CSS property value only contains allowed content (allowlist approach). * Validate that a CSS property value only contains allowed content (allowlist approach).
* Allows: colors (hex, rgb/rgba, hsl/hsla), lengths, numbers, keywords, and var(--name) only (no fallbacks). * Allows: colors (hex, rgb/rgba, hsl/hsla), lengths, numbers, keywords, and var(--name) only (no fallbacks).
* Font properties (--font-*) are validated against a safe font family allowlist instead.
*/ */
function validatePropertyValue(value: string, property: string): void { function validatePropertyValue(value: string, property: string): void {
// Font properties use a dedicated validator that accepts any font name
// but rejects function calls (url(), expression(), etc.).
if (/^--font-/i.test(property)) {
validateFontFamilyValue(value, property);
return;
}
if (!value || value.length === 0) { if (!value || value.length === 0) {
return; // Empty values are allowed return; // Empty values are allowed
} }
@@ -145,79 +209,152 @@ function validatePropertyValue(value: string, property: string): void {
); );
} }
// ─── @font-face validation ──────────────────────────────────────────────────
/** Maximum size of a single base64-encoded font (bytes of decoded data). 2 MB. */
export const MAX_FONT_FILE_SIZE = 2 * 1024 * 1024;
/** Maximum total size of all embedded font data across all @font-face blocks. 10 MB. */
export const MAX_TOTAL_FONT_SIZE = 10 * 1024 * 1024;
/** /**
* Validate that CSS contains only :root { ... } with CSS custom property (variable) declarations. * Extract @font-face blocks from CSS. Returns the blocks and the remaining CSS.
* Must contain exactly :root { ... } and nothing else. * Only matches top-level @font-face blocks (not nested inside other rules).
* Returns the validated CSS or throws an error.
*/ */
export function validateThemeCss(css: string): string { function extractFontFaceBlocks(css: string): {
// Strip multi-line comments before validation fontFaceBlocks: string[];
// Note: Single-line comments (//) are not stripped to avoid corrupting CSS values like URLs remaining: string;
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim(); } {
const fontFaceBlocks: string[] = [];
let remaining = css;
// Must contain exactly :root { ... } and nothing else // Extract @font-face { ... } blocks one at a time using indexOf-based
// Find :root { ... } and extract content, then check there's nothing after // parsing. Each iteration removes the matched block from `remaining`.
const rootMatch = cleaned.match(/^:root\s*\{/); for (;;) {
if (!rootMatch) { const atIdx = remaining.indexOf('@font-face');
throw new Error( if (atIdx === -1) break;
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
); const openBrace = remaining.indexOf('{', atIdx);
if (openBrace === -1) break;
const closeBrace = remaining.indexOf('}', openBrace + 1);
if (closeBrace === -1) break;
fontFaceBlocks.push(remaining.substring(openBrace + 1, closeBrace).trim());
remaining =
remaining.substring(0, atIdx) + remaining.substring(closeBrace + 1);
} }
// Find the opening brace after :root return { fontFaceBlocks, remaining: remaining.trim() };
const rootStart = cleaned.indexOf(':root'); }
const openBrace = cleaned.indexOf('{', rootStart);
if (openBrace === -1) { /**
throw new Error( * Validate @font-face blocks: only data: URIs allowed (no remote URLs).
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', * Enforces size limits to prevent DoS.
); */
function validateFontFaceBlocks(fontFaceBlocks: string[]): void {
let totalSize = 0;
// Match url() with quoted or unquoted content. Quoted URLs use a non-greedy
// match up to the closing quote; unquoted URLs match non-whitespace/non-paren.
const urlRegex = /url\(\s*(?:'([^']*)'|"([^"]*)"|([^'")\s]+))\s*\)/g;
for (const block of fontFaceBlocks) {
urlRegex.lastIndex = 0;
let match;
while ((match = urlRegex.exec(block)) !== null) {
const uri = (match[1] ?? match[2] ?? match[3]).trim();
if (!uri.startsWith('data:')) {
throw new Error(
'Invalid font src: only data: URIs are allowed in @font-face. ' +
'Remote URLs (http/https) are not permitted to protect user privacy. ' +
'Font files are automatically embedded when installing from GitHub.',
);
}
// Estimate decoded size from base64 content
const base64Match = uri.match(/;base64,(.+)$/);
if (base64Match) {
const size = Math.ceil((base64Match[1].length * 3) / 4);
if (size > MAX_FONT_FILE_SIZE) {
throw new Error(
`Font file exceeds maximum size of ${MAX_FONT_FILE_SIZE / 1024 / 1024}MB.`,
);
}
totalSize += size;
}
}
} }
// Find the first closing brace (nested blocks will be caught by the check below) if (totalSize > MAX_TOTAL_FONT_SIZE) {
const closeBrace = cleaned.indexOf('}', openBrace + 1);
if (closeBrace === -1) {
throw new Error( throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', `Total embedded font data exceeds maximum of ${MAX_TOTAL_FONT_SIZE / 1024 / 1024}MB.`,
); );
} }
}
// Extract content inside :root { ... } /**
const rootContent = cleaned.substring(openBrace + 1, closeBrace).trim(); * Split CSS declarations by semicolons, but respect quoted strings and url() contents.
* This is needed because data: URIs contain semicolons (e.g., "data:font/woff2;base64,...").
*/
function splitDeclarations(content: string): string[] {
const declarations: string[] = [];
let start = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let parenDepth = 0;
// Check for forbidden at-rules first (before nested block check, since at-rules with braces would trigger that) for (let i = 0; i < content.length; i++) {
// Comprehensive list of CSS at-rules that should not be allowed const ch = content[i];
// This includes @import, @media, @keyframes, @font-face, @supports, @charset,
// @namespace, @page, @layer, @container, @scope, and any other at-rules if (ch === "'" && !inDoubleQuote && parenDepth === 0) {
inSingleQuote = !inSingleQuote;
} else if (ch === '"' && !inSingleQuote && parenDepth === 0) {
inDoubleQuote = !inDoubleQuote;
} else if (ch === '(' && !inSingleQuote && !inDoubleQuote) {
parenDepth++;
} else if (
ch === ')' &&
!inSingleQuote &&
!inDoubleQuote &&
parenDepth > 0
) {
parenDepth--;
}
if (ch === ';' && !inSingleQuote && !inDoubleQuote && parenDepth === 0) {
const trimmed = content.substring(start, i).trim();
if (trimmed) declarations.push(trimmed);
start = i + 1;
}
}
const trimmed = content.substring(start).trim();
if (trimmed) declarations.push(trimmed);
return declarations;
}
// ─── :root block validation ─────────────────────────────────────────────────
/**
* Validate the content inside a :root { ... } block.
* Only CSS custom properties (--*) with safe values are allowed.
*/
function validateRootContent(rootContent: string): void {
// Check for forbidden at-rules inside :root
if (/@[a-z-]+/i.test(rootContent)) { if (/@[a-z-]+/i.test(rootContent)) {
throw new Error( throw new Error(
'Theme CSS contains forbidden at-rules (@import, @media, @keyframes, etc.). Only CSS variable declarations are allowed inside :root { ... }.', 'Theme CSS contains forbidden at-rules (@import, @media, @keyframes, etc.). Only CSS variable declarations are allowed inside :root { ... }.',
); );
} }
// Check for nested blocks (additional selectors) - should not have any { after extracting :root content // Check for nested blocks
if (/\{/.test(rootContent)) { if (/\{/.test(rootContent)) {
throw new Error( throw new Error(
'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.', 'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.',
); );
} }
// Check that there's nothing after the closing brace for (const decl of splitDeclarations(rootContent)) {
const afterRoot = cleaned.substring(closeBrace + 1).trim();
if (afterRoot.length > 0) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// Parse declarations and validate each one
const declarations = rootContent
.split(';')
.map(d => d.trim())
.filter(d => d.length > 0);
for (const decl of declarations) {
const colonIndex = decl.indexOf(':'); const colonIndex = decl.indexOf(':');
if (colonIndex === -1) { if (colonIndex === -1) {
throw new Error(`Invalid CSS declaration: "${decl}"`); throw new Error(`Invalid CSS declaration: "${decl}"`);
@@ -271,9 +408,220 @@ export function validateThemeCss(css: string): string {
const value = decl.substring(colonIndex + 1).trim(); const value = decl.substring(colonIndex + 1).trim();
validatePropertyValue(value, property); validatePropertyValue(value, property);
} }
}
// Return the original CSS (with :root wrapper) so it can be injected properly // ─── Main validation entry point ────────────────────────────────────────────
return css.trim();
/**
* Validate theme CSS. Accepts:
* 1. Optional @font-face blocks (with data: URI fonts only)
* 2. Exactly one :root { ... } block with CSS variable declarations
*
* @font-face blocks must appear before :root.
* Returns the validated CSS or throws an error.
*/
export function validateThemeCss(css: string): string {
// Strip multi-line comments before validation
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
// Extract @font-face blocks (if any) from the CSS
const { fontFaceBlocks, remaining } = extractFontFaceBlocks(cleaned);
// Validate @font-face blocks (reject remote URLs, enforce size limits)
validateFontFaceBlocks(fontFaceBlocks);
// Now validate the remaining CSS (should be exactly :root { ... })
const rootMatch = remaining.match(/^:root\s*\{/);
if (!rootMatch) {
// If there are @font-face blocks but no :root, that's an error
// If there's nothing at all, that's also an error
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const rootStart = remaining.indexOf(':root');
const openBrace = remaining.indexOf('{', rootStart);
if (openBrace === -1) {
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const closeBrace = remaining.indexOf('}', openBrace + 1);
if (closeBrace === -1) {
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const rootContent = remaining.substring(openBrace + 1, closeBrace).trim();
// Validate :root content
validateRootContent(rootContent);
// Check nothing after :root
const afterRoot = remaining.substring(closeBrace + 1).trim();
if (afterRoot.length > 0) {
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// Return the comment-stripped CSS — this is what was actually validated,
// so we inject exactly what we checked (defense-in-depth).
return cleaned;
}
// ─── Font embedding (install-time) ─────────────────────────────────────────
/** Map of file extensions to font MIME types for data: URI construction. */
const FONT_EXTENSION_MIME: Record<string, string> = {
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
'.otf': 'font/opentype',
};
/** Convert an ArrayBuffer to a base64 string using chunked processing. */
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunks: string[] = [];
// Process in 8 KB chunks to avoid excessive string concatenation
for (let i = 0; i < bytes.length; i += 8192) {
const chunk = bytes.subarray(i, Math.min(i + 8192, bytes.length));
chunks.push(String.fromCharCode(...chunk));
}
return btoa(chunks.join(''));
}
/**
* Embed fonts referenced in @font-face blocks by fetching them from a GitHub
* repo and converting to data: URIs.
*
* This runs at install time only. Relative URL references like
* `url('./fonts/MyFont.woff2')` are resolved relative to the repo's root
* directory and fetched from GitHub's raw content API.
*
* The returned CSS has all font URLs replaced with self-contained data: URIs,
* so no network requests are needed at runtime.
*
* @param css - The raw theme CSS (may contain relative url() references)
* @param repo - GitHub repo in "owner/repo" format
* @returns CSS with all font URLs replaced by data: URIs
*/
export async function embedThemeFonts(
css: string,
repo: string,
): Promise<string> {
const baseUrl = `https://raw.githubusercontent.com/${repo}/refs/heads/main/`;
// Collect all url() references that need fetching across all @font-face blocks
const urlRegex = /url\(\s*(?:(['"])([^'"]*?)\1|([^'")\s]+))\s*\)/g;
type FontRef = {
fullMatch: string;
quote: string;
path: string;
cleanPath: string;
mime: string;
};
const fontRefs: FontRef[] = [];
// Use extractFontFaceBlocks-style indexOf parsing to find @font-face blocks
// and their url() references, without duplicating the regex approach
let searchCss = css;
let offset = 0;
for (;;) {
const atIdx = searchCss.indexOf('@font-face', 0);
if (atIdx === -1) break;
const openBrace = searchCss.indexOf('{', atIdx);
if (openBrace === -1) break;
const closeBrace = searchCss.indexOf('}', openBrace + 1);
if (closeBrace === -1) break;
const blockContent = searchCss.substring(openBrace + 1, closeBrace);
// Find url() references within this block
let urlMatch;
urlRegex.lastIndex = 0;
while ((urlMatch = urlRegex.exec(blockContent)) !== null) {
const fullMatch = urlMatch[0];
const quote = urlMatch[1] || '';
const path = urlMatch[2] ?? urlMatch[3];
// Skip data: URIs — already embedded
if (path.startsWith('data:')) continue;
if (/^https?:\/\//i.test(path)) {
throw new Error(
`Remote font URL "${path}" is not allowed. Only relative paths to fonts in the same GitHub repo are supported.`,
);
}
const cleanPath = path.replace(/^\.\//, '');
if (cleanPath.startsWith('/') || cleanPath.includes('..')) {
throw new Error(
`Font path "${path}" is not allowed. Only relative paths within the repo are supported (no "/" prefix or ".." segments).`,
);
}
const ext = cleanPath.substring(cleanPath.lastIndexOf('.')).toLowerCase();
const mime = FONT_EXTENSION_MIME[ext];
if (!mime) {
throw new Error(
`Unsupported font file extension "${ext}". Supported: ${Object.keys(FONT_EXTENSION_MIME).join(', ')}.`,
);
}
fontRefs.push({ fullMatch, quote, path, cleanPath, mime });
}
offset = closeBrace + 1;
searchCss = searchCss.substring(offset);
}
if (fontRefs.length === 0) return css;
// Fetch fonts sequentially to enforce a running total size budget
const fetched: { ref: FontRef; dataUri: string }[] = [];
let totalBytes = 0;
for (const ref of fontRefs) {
const fontUrl = baseUrl + ref.cleanPath;
const response = await fetch(fontUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch font file "${ref.cleanPath}" from ${fontUrl}: ${response.status} ${response.statusText}`,
);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > MAX_FONT_FILE_SIZE) {
throw new Error(
`Font file "${ref.cleanPath}" exceeds maximum size of ${MAX_FONT_FILE_SIZE / 1024 / 1024}MB.`,
);
}
totalBytes += buffer.byteLength;
if (totalBytes > MAX_TOTAL_FONT_SIZE) {
throw new Error(
`Total embedded font data exceeds maximum of ${MAX_TOTAL_FONT_SIZE / 1024 / 1024}MB.`,
);
}
const base64 = arrayBufferToBase64(buffer);
fetched.push({ ref, dataUri: `data:${ref.mime};base64,${base64}` });
}
// Replace each url() reference with its data: URI
let result = css;
for (const { ref, dataUri } of fetched) {
const q = ref.quote || "'";
result = result.replace(ref.fullMatch, `url(${q}${dataUri}${q})`);
}
return result;
} }
/** /**

View File

@@ -101,3 +101,30 @@ or
); );
}); });
``` ```
## CLI Usage
The examples above are shown in JavaScript. If you're using the [CLI tool](../cli.md), you can express many of the same queries with command-line flags. Here's how the JS patterns translate:
```bash
# Select specific fields (JS: .select(['date', 'amount', 'payee.name']))
actual query run --table transactions --select "date,amount,payee.name"
# Filter by condition (JS: .filter({ amount: { $lt: 0 } }))
actual query run --table transactions --filter '{"amount":{"$lt":0}}'
# Order by field descending (JS: .orderBy([{ date: 'desc' }]))
actual query run --table transactions --order-by "date:desc"
# Search by month (JS: .filter({ date: { $transform: '$month', $eq: '2021-01' } }))
actual query run --table transactions --filter '{"date":{"$transform":"$month","$eq":"2021-01"}}'
# Group by payee with sum — use --file for aggregate queries
echo '{"table":"transactions","groupBy":["payee.name"],"select":["payee.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
# Count transactions (JS: .calculate({ $count: '*' }))
actual query run --table transactions --count
# Quick shortcut: last 10 transactions
actual query run --last 10
```

View File

@@ -274,16 +274,80 @@ actual schedules delete <id>
### Query (ActualQL) ### Query (ActualQL)
Run queries using [ActualQL](./actual-ql/index.md): Run queries using [ActualQL](./actual-ql/index.md).
#### Subcommands
| Subcommand | Description |
| ---------------------- | --------------------------------- |
| `query run` | Execute an AQL query |
| `query tables` | List available tables |
| `query fields <table>` | List fields and types for a table |
#### `query run` Options
| Option | Description |
| --------------------- | ------------------------------------------------------------------------------------------- |
| `--table <table>` | Table to query (use `actual query tables` to list) |
| `--select <fields>` | Comma-separated fields to select |
| `--filter <json>` | Filter as JSON (e.g. `'{"amount":{"$lt":0}}'`) |
| `--where <json>` | Alias for `--filter` (cannot be used together) |
| `--order-by <fields>` | Fields with optional direction: `field1:desc,field2` (default: asc) |
| `--limit <n>` | Limit number of results |
| `--offset <n>` | Skip first N results (for pagination) |
| `--last <n>` | Show last N transactions (shortcut: implies `--table transactions`, `--order-by date:desc`) |
| `--count` | Count matching rows instead of returning them |
| `--group-by <fields>` | Comma-separated fields to group by |
| `--file <path>` | Read query from JSON file (use `-` for stdin) |
#### Examples
```bash ```bash
# Run a query (inline) # Show last 5 transactions (convenience shortcut)
actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10 actual query run --last 5
# Run a query (from file) # Override default columns with --last
actual query run --last 10 --select "date,amount,notes"
# Transactions ordered by date descending with limit
actual query run --table transactions --select "date,amount,payee.name" --order-by "date:desc" --limit 10
# Filter with JSON — negative amounts (expenses)
actual query run --table transactions --filter '{"amount":{"$lt":0}}' --limit 5
# Use --where (alias for --filter, more intuitive for SQL users)
actual query run --table transactions --where '{"payee.name":"Grocery Store"}' --limit 5
# Count all transactions
actual query run --table transactions --count
# Count with a filter
actual query run --table transactions --filter '{"category.name":"Groceries"}' --count
# Group by category with aggregate (use --file for aggregate expressions)
echo '{"table":"transactions","groupBy":["category.name"],"select":["category.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
# Pagination: skip first 20, show next 10
actual query run --table transactions --order-by "date:desc" --limit 10 --offset 20
# Multi-field ordering
actual query run --table transactions --order-by "date:desc,amount:asc" --limit 10
# Run a query from a JSON file
actual query run --file query.json actual query run --file query.json
# Pipe query from stdin
echo '{"table":"transactions","select":["date","amount"],"limit":5}' | actual query run --file -
# List available tables
actual query tables
# List fields for a table
actual query fields transactions
``` ```
See [ActualQL](./actual-ql/index.md) for full filter/function reference including `$transform`, `$month`, `$year`, and aggregate functions.
### Server ### Server
```bash ```bash

View File

@@ -128,6 +128,10 @@ app.get('/metrics', (_req, res) => {
app.use((req, res, next) => { app.use((req, res, next) => {
res.set('Cross-Origin-Opener-Policy', 'same-origin'); res.set('Cross-Origin-Opener-Policy', 'same-origin');
res.set('Cross-Origin-Embedder-Policy', 'require-corp'); res.set('Cross-Origin-Embedder-Policy', 'require-corp');
res.set(
'Content-Security-Policy',
"default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;",
);
next(); next();
}); });
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Custom Themes: allow using a custom font

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
cli: improved aql support

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Add post-merge hook to automatically install dependencies when yarn.lock changes after merges.