mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-23 04:50:12 -05:00
Compare commits
1 Commits
master
...
claude/imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dce328b00 |
@@ -3,7 +3,11 @@ import { Command } from 'commander';
|
||||
|
||||
import { printOutput } from '../output';
|
||||
|
||||
import { parseOrderBy, registerQueryCommand } from './query';
|
||||
import {
|
||||
expandSelectAliases,
|
||||
parseOrderBy,
|
||||
registerQueryCommand,
|
||||
} from './query';
|
||||
|
||||
vi.mock('@actual-app/api', () => {
|
||||
const queryObj = {
|
||||
@@ -342,5 +346,87 @@ describe('query commands', () => {
|
||||
'Unknown table "unknown"',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes descriptions in field output', async () => {
|
||||
await run(['query', 'fields', 'transactions']);
|
||||
|
||||
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}>;
|
||||
const amountField = output.find(f => f.name === 'amount');
|
||||
expect(amountField?.description).toContain('cents');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describe subcommand', () => {
|
||||
it('outputs schema for all tables', async () => {
|
||||
await run(['query', 'describe']);
|
||||
|
||||
const output = vi.mocked(printOutput).mock.calls[0][0] as Record<
|
||||
string,
|
||||
unknown[]
|
||||
>;
|
||||
expect(output).toHaveProperty('transactions');
|
||||
expect(output).toHaveProperty('accounts');
|
||||
expect(output).toHaveProperty('categories');
|
||||
expect(output).toHaveProperty('payees');
|
||||
expect(output).toHaveProperty('rules');
|
||||
expect(output).toHaveProperty('schedules');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--exclude-transfers flag', () => {
|
||||
it('adds transfer_id null filter for transactions', async () => {
|
||||
await run([
|
||||
'query',
|
||||
'run',
|
||||
'--table',
|
||||
'transactions',
|
||||
'--exclude-transfers',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.filter).toHaveBeenCalledWith({ transfer_id: { $eq: null } });
|
||||
});
|
||||
|
||||
it('errors when used with non-transactions table', async () => {
|
||||
await expect(
|
||||
run(['query', 'run', '--table', 'accounts', '--exclude-transfers']),
|
||||
).rejects.toThrow(
|
||||
'--exclude-transfers can only be used with --table transactions',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandSelectAliases', () => {
|
||||
it('expands transaction aliases', () => {
|
||||
expect(
|
||||
expandSelectAliases('transactions', [
|
||||
'date',
|
||||
'payee',
|
||||
'category',
|
||||
'amount',
|
||||
]),
|
||||
).toEqual(['date', 'payee.name', 'category.name', 'amount']);
|
||||
});
|
||||
|
||||
it('expands account alias', () => {
|
||||
expect(expandSelectAliases('transactions', ['account'])).toEqual([
|
||||
'account.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes through unknown fields unchanged', () => {
|
||||
expect(expandSelectAliases('transactions', ['notes'])).toEqual(['notes']);
|
||||
});
|
||||
|
||||
it('returns fields unchanged for tables without aliases', () => {
|
||||
expect(expandSelectAliases('rules', ['id', 'stage'])).toEqual([
|
||||
'id',
|
||||
'stage',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Command } from 'commander';
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
import { parseIntFlag } from '../utils';
|
||||
import { CliError, parseIntFlag } from '../utils';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
@@ -43,71 +43,166 @@ export function parseOrderBy(
|
||||
}
|
||||
|
||||
// TODO: Import schema from API once it exposes table/field metadata
|
||||
const TABLE_SCHEMA: Record<
|
||||
string,
|
||||
Record<string, { type: string; ref?: string }>
|
||||
> = {
|
||||
type FieldInfo = { type: string; ref?: string; description?: string };
|
||||
|
||||
const TABLE_SCHEMA: Record<string, Record<string, FieldInfo>> = {
|
||||
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' },
|
||||
id: { type: 'id', description: 'Unique transaction identifier' },
|
||||
account: { type: 'id', ref: 'accounts', description: 'Account ID' },
|
||||
date: { type: 'date', description: 'Transaction date (YYYY-MM-DD)' },
|
||||
amount: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'Amount in cents (e.g. 1000 = $10.00). Negative = expense, positive = income',
|
||||
},
|
||||
payee: { type: 'id', ref: 'payees', description: 'Payee ID' },
|
||||
category: { type: 'id', ref: 'categories', description: 'Category ID' },
|
||||
notes: { type: 'string', description: 'Transaction notes/memo' },
|
||||
imported_id: {
|
||||
type: 'string',
|
||||
description: 'External ID from bank import',
|
||||
},
|
||||
transfer_id: {
|
||||
type: 'id',
|
||||
description:
|
||||
'Linked transaction ID for transfers. Non-null means this is a transfer between own accounts',
|
||||
},
|
||||
cleared: { type: 'boolean', description: 'Whether transaction is cleared' },
|
||||
reconciled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether transaction is reconciled',
|
||||
},
|
||||
starting_balance_flag: {
|
||||
type: 'boolean',
|
||||
description: 'True for the starting balance transaction',
|
||||
},
|
||||
imported_payee: {
|
||||
type: 'string',
|
||||
description: 'Original payee name from bank import',
|
||||
},
|
||||
is_parent: {
|
||||
type: 'boolean',
|
||||
description: 'True if this is a split parent transaction',
|
||||
},
|
||||
is_child: {
|
||||
type: 'boolean',
|
||||
description: 'True if this is a split child transaction',
|
||||
},
|
||||
parent_id: {
|
||||
type: 'id',
|
||||
description: 'Parent transaction ID for split children',
|
||||
},
|
||||
sort_order: { type: 'float', description: 'Sort order within a day' },
|
||||
schedule: {
|
||||
type: 'id',
|
||||
ref: 'schedules',
|
||||
description: 'Linked schedule ID',
|
||||
},
|
||||
'account.name': {
|
||||
type: 'string',
|
||||
ref: 'accounts',
|
||||
description: 'Resolved account name',
|
||||
},
|
||||
'payee.name': {
|
||||
type: 'string',
|
||||
ref: 'payees',
|
||||
description: 'Resolved payee name',
|
||||
},
|
||||
'category.name': {
|
||||
type: 'string',
|
||||
ref: 'categories',
|
||||
description: 'Resolved category name',
|
||||
},
|
||||
'category.group.name': {
|
||||
type: 'string',
|
||||
ref: 'category_groups',
|
||||
description: 'Resolved category group name',
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
id: { type: 'id' },
|
||||
name: { type: 'string' },
|
||||
offbudget: { type: 'boolean' },
|
||||
closed: { type: 'boolean' },
|
||||
sort_order: { type: 'float' },
|
||||
id: { type: 'id', description: 'Unique account identifier' },
|
||||
name: { type: 'string', description: 'Account name' },
|
||||
offbudget: {
|
||||
type: 'boolean',
|
||||
description: 'True if account is off-budget (tracking)',
|
||||
},
|
||||
closed: { type: 'boolean', description: 'True if account is closed' },
|
||||
sort_order: { type: 'float', description: 'Display sort order' },
|
||||
},
|
||||
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' },
|
||||
id: { type: 'id', description: 'Unique category identifier' },
|
||||
name: { type: 'string', description: 'Category name' },
|
||||
is_income: { type: 'boolean', description: 'True for income categories' },
|
||||
group_id: {
|
||||
type: 'id',
|
||||
ref: 'category_groups',
|
||||
description: 'Category group ID',
|
||||
},
|
||||
sort_order: { type: 'float', description: 'Display sort order' },
|
||||
hidden: { type: 'boolean', description: 'True if category is hidden' },
|
||||
'group.name': {
|
||||
type: 'string',
|
||||
ref: 'category_groups',
|
||||
description: 'Resolved category group name',
|
||||
},
|
||||
},
|
||||
payees: {
|
||||
id: { type: 'id' },
|
||||
name: { type: 'string' },
|
||||
transfer_acct: { type: 'id', ref: 'accounts' },
|
||||
id: { type: 'id', description: 'Unique payee identifier' },
|
||||
name: { type: 'string', description: 'Payee name' },
|
||||
transfer_acct: {
|
||||
type: 'id',
|
||||
ref: 'accounts',
|
||||
description:
|
||||
'Linked account ID for transfer payees. Non-null means this payee represents a transfer to/from this account',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
id: { type: 'id' },
|
||||
stage: { type: 'string' },
|
||||
conditions_op: { type: 'string' },
|
||||
conditions: { type: 'json' },
|
||||
actions: { type: 'json' },
|
||||
id: { type: 'id', description: 'Unique rule identifier' },
|
||||
stage: { type: 'string', description: 'Rule stage (pre, post, null)' },
|
||||
conditions_op: {
|
||||
type: 'string',
|
||||
description: 'How conditions combine: "and" or "or"',
|
||||
},
|
||||
conditions: { type: 'json', description: 'Rule conditions as JSON array' },
|
||||
actions: { type: 'json', description: 'Rule actions as JSON array' },
|
||||
},
|
||||
schedules: {
|
||||
id: { type: 'id' },
|
||||
name: { type: 'string' },
|
||||
rule: { type: 'id', ref: 'rules' },
|
||||
next_date: { type: 'date' },
|
||||
completed: { type: 'boolean' },
|
||||
id: { type: 'id', description: 'Unique schedule identifier' },
|
||||
name: { type: 'string', description: 'Schedule name' },
|
||||
rule: {
|
||||
type: 'id',
|
||||
ref: 'rules',
|
||||
description: 'Associated rule ID',
|
||||
},
|
||||
next_date: {
|
||||
type: 'date',
|
||||
description: 'Next occurrence date (YYYY-MM-DD)',
|
||||
},
|
||||
completed: {
|
||||
type: 'boolean',
|
||||
description: 'True if schedule is completed',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const FIELD_ALIASES: Record<string, Record<string, string>> = {
|
||||
transactions: {
|
||||
payee: 'payee.name',
|
||||
category: 'category.name',
|
||||
account: 'account.name',
|
||||
group: 'category.group.name',
|
||||
},
|
||||
categories: {
|
||||
group: 'group.name',
|
||||
},
|
||||
};
|
||||
|
||||
export function expandSelectAliases(table: string, fields: string[]): string[] {
|
||||
const aliases = FIELD_ALIASES[table];
|
||||
if (!aliases) return fields;
|
||||
return fields.map(f => aliases[f.trim()] ?? f);
|
||||
}
|
||||
|
||||
const AVAILABLE_TABLES = Object.keys(TABLE_SCHEMA).join(', ');
|
||||
|
||||
const LAST_DEFAULT_SELECT = [
|
||||
@@ -163,7 +258,10 @@ function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
|
||||
const table =
|
||||
cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined);
|
||||
if (!table) {
|
||||
throw new Error('--table is required (or use --file or --last)');
|
||||
throw new CliError(
|
||||
'--table is required (or use --file or --last)',
|
||||
'Run "actual query tables" to see available tables, or use --last <n> for recent transactions.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!(table in TABLE_SCHEMA)) {
|
||||
@@ -180,19 +278,38 @@ function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
|
||||
throw new Error('--count and --select are mutually exclusive');
|
||||
}
|
||||
|
||||
if (cmdOpts.excludeTransfers && table !== 'transactions') {
|
||||
throw new Error(
|
||||
'--exclude-transfers can only be used with --table transactions',
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
expandSelectAliases(table, cmdOpts.select.split(',')),
|
||||
);
|
||||
} else if (last !== undefined) {
|
||||
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
|
||||
}
|
||||
|
||||
const filterStr = cmdOpts.filter ?? cmdOpts.where;
|
||||
if (filterStr) {
|
||||
queryObj = queryObj.filter(JSON.parse(filterStr));
|
||||
try {
|
||||
queryObj = queryObj.filter(JSON.parse(filterStr));
|
||||
} catch {
|
||||
throw new CliError(
|
||||
'Invalid JSON in --filter.',
|
||||
`Ensure valid JSON. Example: --filter '{"amount":{"$lt":0}}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdOpts.excludeTransfers) {
|
||||
queryObj = queryObj.filter({ transfer_id: { $eq: null } });
|
||||
}
|
||||
|
||||
const orderByStr =
|
||||
@@ -249,8 +366,15 @@ Examples:
|
||||
# Pipe query from stdin
|
||||
echo '{"table":"transactions","limit":5}' | actual query run --file -
|
||||
|
||||
# Exclude transfers from results
|
||||
actual query run --table transactions --exclude-transfers --last 10
|
||||
|
||||
# Use shorthand aliases (payee = payee.name, category = category.name)
|
||||
actual query run --table transactions --select "date,payee,category,amount" --last 10
|
||||
|
||||
Available tables: ${AVAILABLE_TABLES}
|
||||
Use "actual query tables" and "actual query fields <table>" for schema info.
|
||||
Use "actual query describe" for full schema with all tables, fields, and descriptions.
|
||||
|
||||
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/`;
|
||||
@@ -292,6 +416,11 @@ export function registerQueryCommand(program: Command) {
|
||||
'--file <path>',
|
||||
'Read full query object from JSON file (use - for stdin)',
|
||||
)
|
||||
.option(
|
||||
'--exclude-transfers',
|
||||
'Exclude transfer transactions (only for --table transactions)',
|
||||
false,
|
||||
)
|
||||
.addHelpText('after', RUN_EXAMPLES)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
@@ -338,7 +467,27 @@ export function registerQueryCommand(program: Command) {
|
||||
name,
|
||||
type: info.type,
|
||||
...(info.ref ? { ref: info.ref } : {}),
|
||||
...(info.description ? { description: info.description } : {}),
|
||||
}));
|
||||
printOutput(fields, opts.format);
|
||||
});
|
||||
|
||||
query
|
||||
.command('describe')
|
||||
.description(
|
||||
'Output full schema for all tables (fields, types, relationships, descriptions)',
|
||||
)
|
||||
.action(() => {
|
||||
const opts = program.opts();
|
||||
const schema: Record<string, unknown[]> = {};
|
||||
for (const [table, fields] of Object.entries(TABLE_SCHEMA)) {
|
||||
schema[table] = Object.entries(fields).map(([name, info]) => ({
|
||||
name,
|
||||
type: info.type,
|
||||
...(info.ref ? { ref: info.ref } : {}),
|
||||
...(info.description ? { description: info.description } : {}),
|
||||
}));
|
||||
}
|
||||
printOutput(schema, opts.format);
|
||||
});
|
||||
}
|
||||
|
||||
170
packages/cli/src/commands/transactions.test.ts
Normal file
170
packages/cli/src/commands/transactions.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { printOutput } from '../output';
|
||||
|
||||
import { registerTransactionsCommand } from './transactions';
|
||||
|
||||
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: [] }),
|
||||
addTransactions: vi.fn().mockResolvedValue([]),
|
||||
importTransactions: vi.fn().mockResolvedValue({ added: [], updated: [] }),
|
||||
updateTransaction: vi.fn().mockResolvedValue(undefined),
|
||||
deleteTransaction: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
registerTransactionsCommand(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('transactions list', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('uses AQL query with resolved field names', async () => {
|
||||
await run([
|
||||
'transactions',
|
||||
'list',
|
||||
'--account',
|
||||
'acc-1',
|
||||
'--start',
|
||||
'2025-01-01',
|
||||
'--end',
|
||||
'2025-01-31',
|
||||
]);
|
||||
|
||||
expect(api.q).toHaveBeenCalledWith('transactions');
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.select).toHaveBeenCalledWith([
|
||||
'*',
|
||||
'account.name',
|
||||
'payee.name',
|
||||
'category.name',
|
||||
]);
|
||||
expect(qObj.filter).toHaveBeenCalledWith({
|
||||
account: 'acc-1',
|
||||
date: { $gte: '2025-01-01', $lte: '2025-01-31' },
|
||||
});
|
||||
expect(qObj.orderBy).toHaveBeenCalledWith([{ date: 'desc' }]);
|
||||
});
|
||||
|
||||
it('defaults --start to 30 days before --end', async () => {
|
||||
await run([
|
||||
'transactions',
|
||||
'list',
|
||||
'--account',
|
||||
'acc-1',
|
||||
'--end',
|
||||
'2025-02-28',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.filter).toHaveBeenCalledWith({
|
||||
account: 'acc-1',
|
||||
date: { $gte: '2025-01-29', $lte: '2025-02-28' },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults both --start and --end when omitted', async () => {
|
||||
await run(['transactions', 'list', '--account', 'acc-1']);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
const filterCall = qObj.filter.mock.calls[0][0];
|
||||
expect(filterCall.account).toBe('acc-1');
|
||||
expect(filterCall.date.$gte).toBeDefined();
|
||||
expect(filterCall.date.$lte).toBeDefined();
|
||||
});
|
||||
|
||||
it('excludes transfers when --exclude-transfers is set', async () => {
|
||||
await run([
|
||||
'transactions',
|
||||
'list',
|
||||
'--account',
|
||||
'acc-1',
|
||||
'--start',
|
||||
'2025-01-01',
|
||||
'--end',
|
||||
'2025-01-31',
|
||||
'--exclude-transfers',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.filter).toHaveBeenCalledWith({
|
||||
account: 'acc-1',
|
||||
date: { $gte: '2025-01-01', $lte: '2025-01-31' },
|
||||
transfer_id: { $eq: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('outputs result.data from AQL query', async () => {
|
||||
const mockData = [{ id: 't1', amount: -500 }];
|
||||
vi.mocked(api.aqlQuery).mockResolvedValueOnce({ data: mockData });
|
||||
|
||||
await run([
|
||||
'transactions',
|
||||
'list',
|
||||
'--account',
|
||||
'acc-1',
|
||||
'--start',
|
||||
'2025-01-01',
|
||||
'--end',
|
||||
'2025-01-31',
|
||||
]);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith(mockData, undefined);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { Command } from 'commander';
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
import { defaultDateRange } from '../utils';
|
||||
|
||||
export function registerTransactionsCommand(program: Command) {
|
||||
const transactions = program
|
||||
@@ -14,17 +15,32 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.command('list')
|
||||
.description('List transactions for an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.requiredOption('--start <date>', 'Start date (YYYY-MM-DD)')
|
||||
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)')
|
||||
.option(
|
||||
'--start <date>',
|
||||
'Start date (YYYY-MM-DD, defaults to 30 days ago)',
|
||||
)
|
||||
.option('--end <date>', 'End date (YYYY-MM-DD, defaults to today)')
|
||||
.option('--exclude-transfers', 'Exclude transfer transactions', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getTransactions(
|
||||
cmdOpts.account,
|
||||
cmdOpts.start,
|
||||
cmdOpts.end,
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
const { start, end } = defaultDateRange(cmdOpts.start, cmdOpts.end);
|
||||
|
||||
const filter: Record<string, unknown> = {
|
||||
account: cmdOpts.account,
|
||||
date: { $gte: start, $lte: end },
|
||||
};
|
||||
if (cmdOpts.excludeTransfers) {
|
||||
filter.transfer_id = { $eq: null };
|
||||
}
|
||||
|
||||
const queryObj = api
|
||||
.q('transactions')
|
||||
.select(['*', 'account.name', 'payee.name', 'category.name'])
|
||||
.filter(filter)
|
||||
.orderBy([{ date: 'desc' }]);
|
||||
const result = await api.aqlQuery(queryObj);
|
||||
printOutput(result.data, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { registerSchedulesCommand } from './commands/schedules';
|
||||
import { registerServerCommand } from './commands/server';
|
||||
import { registerTagsCommand } from './commands/tags';
|
||||
import { registerTransactionsCommand } from './commands/transactions';
|
||||
import { CliError } from './utils';
|
||||
|
||||
declare const __CLI_VERSION__: string;
|
||||
|
||||
@@ -66,5 +67,8 @@ function normalizeThrownMessage(err: unknown): string {
|
||||
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||
const message = normalizeThrownMessage(err);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
if (err instanceof CliError && err.suggestion) {
|
||||
process.stderr.write(`Suggestion: ${err.suggestion}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -60,6 +60,22 @@ describe('formatOutput', () => {
|
||||
expect(result).toContain('a');
|
||||
expect(result).toContain('b');
|
||||
});
|
||||
|
||||
it('flattens nested objects instead of showing [object Object]', () => {
|
||||
const data = [{ payee: { id: 'p1', name: 'Grocery' } }];
|
||||
const result = formatOutput(data, 'table');
|
||||
expect(result).toContain('Grocery');
|
||||
expect(result).not.toContain('[object Object]');
|
||||
});
|
||||
|
||||
it('flattens nested objects in key-value table', () => {
|
||||
const result = formatOutput(
|
||||
{ payee: { id: 'p1', name: 'Grocery' } },
|
||||
'table',
|
||||
);
|
||||
expect(result).toContain('Grocery');
|
||||
expect(result).not.toContain('[object Object]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csv', () => {
|
||||
@@ -112,6 +128,24 @@ describe('formatOutput', () => {
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('a,b');
|
||||
});
|
||||
|
||||
it('flattens nested objects instead of showing [object Object]', () => {
|
||||
const data = [{ payee: { id: 'p1', name: 'Grocery' } }];
|
||||
const result = formatOutput(data, 'csv');
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('payee');
|
||||
expect(lines[1]).toContain('Grocery');
|
||||
expect(lines[1]).not.toContain('[object Object]');
|
||||
});
|
||||
|
||||
it('flattens nested objects in single-object csv', () => {
|
||||
const result = formatOutput(
|
||||
{ payee: { id: 'p1', name: 'Grocery' } },
|
||||
'csv',
|
||||
);
|
||||
expect(result).toContain('Grocery');
|
||||
expect(result).not.toContain('[object Object]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ import Table from 'cli-table3';
|
||||
|
||||
export type OutputFormat = 'json' | 'table' | 'csv';
|
||||
|
||||
function flattenValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatOutput(
|
||||
data: unknown,
|
||||
format: OutputFormat = 'json',
|
||||
@@ -23,7 +29,7 @@ function formatTable(data: unknown): string {
|
||||
if (data && typeof data === 'object') {
|
||||
const table = new Table();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
table.push({ [key]: String(value) });
|
||||
table.push({ [key]: flattenValue(value) });
|
||||
}
|
||||
return table.toString();
|
||||
}
|
||||
@@ -39,7 +45,7 @@ function formatTable(data: unknown): string {
|
||||
|
||||
for (const row of data) {
|
||||
const r = row as Record<string, unknown>;
|
||||
table.push(keys.map(k => String(r[k] ?? '')));
|
||||
table.push(keys.map(k => flattenValue(r[k])));
|
||||
}
|
||||
|
||||
return table.toString();
|
||||
@@ -50,7 +56,9 @@ function formatCsv(data: unknown): string {
|
||||
if (data && typeof data === 'object') {
|
||||
const entries = Object.entries(data);
|
||||
const header = entries.map(([k]) => escapeCsv(k)).join(',');
|
||||
const values = entries.map(([, v]) => escapeCsv(String(v))).join(',');
|
||||
const values = entries
|
||||
.map(([, v]) => escapeCsv(flattenValue(v)))
|
||||
.join(',');
|
||||
return header + '\n' + values;
|
||||
}
|
||||
return String(data);
|
||||
@@ -64,7 +72,7 @@ function formatCsv(data: unknown): string {
|
||||
const header = keys.map(k => escapeCsv(k)).join(',');
|
||||
const rows = data.map(row => {
|
||||
const r = row as Record<string, unknown>;
|
||||
return keys.map(k => escapeCsv(String(r[k] ?? ''))).join(',');
|
||||
return keys.map(k => escapeCsv(flattenValue(r[k]))).join(',');
|
||||
});
|
||||
|
||||
return [header, ...rows].join('\n');
|
||||
|
||||
@@ -1,4 +1,56 @@
|
||||
import { parseBoolFlag, parseIntFlag } from './utils';
|
||||
import {
|
||||
CliError,
|
||||
defaultDateRange,
|
||||
parseBoolFlag,
|
||||
parseIntFlag,
|
||||
} from './utils';
|
||||
|
||||
describe('CliError', () => {
|
||||
it('stores message and suggestion', () => {
|
||||
const err = new CliError('something failed', 'try this instead');
|
||||
expect(err.message).toBe('something failed');
|
||||
expect(err.suggestion).toBe('try this instead');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('works without suggestion', () => {
|
||||
const err = new CliError('something failed');
|
||||
expect(err.message).toBe('something failed');
|
||||
expect(err.suggestion).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultDateRange', () => {
|
||||
it('returns both dates when both provided', () => {
|
||||
expect(defaultDateRange('2025-01-01', '2025-01-31')).toEqual({
|
||||
start: '2025-01-01',
|
||||
end: '2025-01-31',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults start to 30 days before end', () => {
|
||||
expect(defaultDateRange(undefined, '2025-02-28')).toEqual({
|
||||
start: '2025-01-29',
|
||||
end: '2025-02-28',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults end to today when only start provided', () => {
|
||||
const result = defaultDateRange('2025-01-01');
|
||||
expect(result.start).toBe('2025-01-01');
|
||||
expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it('defaults both to last 30 days when neither provided', () => {
|
||||
const result = defaultDateRange();
|
||||
expect(result.start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const startDate = new Date(result.start);
|
||||
const endDate = new Date(result.end);
|
||||
const diffDays = (endDate.getTime() - startDate.getTime()) / 86400000;
|
||||
expect(diffDays).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBoolFlag', () => {
|
||||
it('parses "true"', () => {
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
export function defaultDateRange(
|
||||
start?: string,
|
||||
end?: string,
|
||||
): { start: string; end: string } {
|
||||
const endDate = end ?? new Date().toLocaleDateString('en-CA');
|
||||
if (start) return { start, end: endDate };
|
||||
const d = new Date(endDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() - 30);
|
||||
return { start: d.toLocaleDateString('en-CA'), end: endDate };
|
||||
}
|
||||
|
||||
export class CliError extends Error {
|
||||
suggestion?: string;
|
||||
constructor(message: string, suggestion?: string) {
|
||||
super(message);
|
||||
this.suggestion = suggestion;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBoolFlag(value: string, flagName: string): boolean {
|
||||
if (value !== 'true' && value !== 'false') {
|
||||
throw new Error(
|
||||
|
||||
@@ -31,8 +31,7 @@
|
||||
body,
|
||||
button,
|
||||
input {
|
||||
font-family: var(
|
||||
--font-family,
|
||||
font-family:
|
||||
'Inter Variable',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
@@ -46,8 +45,7 @@
|
||||
'Helvetica Neue',
|
||||
'Helvetica',
|
||||
'Arial',
|
||||
sans-serif
|
||||
);
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -69,8 +67,7 @@
|
||||
input,
|
||||
textarea {
|
||||
font-size: 1em;
|
||||
font-family: var(
|
||||
--font-family,
|
||||
font-family:
|
||||
'Inter Variable',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
@@ -84,8 +81,7 @@
|
||||
'Helvetica Neue',
|
||||
'Helvetica',
|
||||
'Arial',
|
||||
sans-serif
|
||||
);
|
||||
sans-serif;
|
||||
font-feature-settings: 'ss01', 'ss04';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
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'; font-src 'self' data:; 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'; connect-src http: https:;
|
||||
|
||||
/kcab/*
|
||||
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:;
|
||||
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:;
|
||||
|
||||
/*.wasm
|
||||
Content-Type: application/wasm
|
||||
|
||||
@@ -17,7 +17,6 @@ import { Link } from '@desktop-client/components/common/Link';
|
||||
import { FixedSizeList } from '@desktop-client/components/FixedSizeList';
|
||||
import { useThemeCatalog } from '@desktop-client/hooks/useThemeCatalog';
|
||||
import {
|
||||
embedThemeFonts,
|
||||
extractRepoOwner,
|
||||
fetchThemeCss,
|
||||
generateThemeId,
|
||||
@@ -167,12 +166,8 @@ export function ThemeInstaller({
|
||||
setSelectedCatalogTheme(theme);
|
||||
|
||||
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({
|
||||
css: cssWithFonts,
|
||||
css: fetchThemeCss(theme.repo),
|
||||
name: theme.name,
|
||||
repo: normalizedRepo,
|
||||
id: generateThemeId(normalizedRepo),
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
// oxlint-disable eslint/no-script-url
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
embedThemeFonts,
|
||||
MAX_FONT_FILE_SIZE,
|
||||
parseInstalledTheme,
|
||||
validateThemeCss,
|
||||
} from './customThemes';
|
||||
import { parseInstalledTheme, validateThemeCss } 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('valid CSS', () => {
|
||||
it('should accept valid :root with CSS variables', () => {
|
||||
@@ -88,7 +74,7 @@ describe('validateThemeCss', () => {
|
||||
},
|
||||
])('should reject $description', ({ css }) => {
|
||||
expect(() => validateThemeCss(css)).toThrow(
|
||||
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -104,7 +90,7 @@ describe('validateThemeCss', () => {
|
||||
color: red;
|
||||
}`,
|
||||
expectedError:
|
||||
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
},
|
||||
{
|
||||
description: 'multiple selectors',
|
||||
@@ -115,7 +101,7 @@ describe('validateThemeCss', () => {
|
||||
--color-primary: #ffffff;
|
||||
}`,
|
||||
expectedError:
|
||||
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
},
|
||||
{
|
||||
description: 'media queries',
|
||||
@@ -128,7 +114,7 @@ describe('validateThemeCss', () => {
|
||||
}
|
||||
}`,
|
||||
expectedError:
|
||||
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
},
|
||||
{
|
||||
description: 'custom selector before :root',
|
||||
@@ -139,7 +125,7 @@ describe('validateThemeCss', () => {
|
||||
--color-primary: #007bff;
|
||||
}`,
|
||||
expectedError:
|
||||
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
},
|
||||
])('should reject CSS with $description', ({ css, expectedError }) => {
|
||||
expect(() => validateThemeCss(css)).toThrow(expectedError);
|
||||
@@ -285,7 +271,7 @@ describe('validateThemeCss', () => {
|
||||
},
|
||||
])('should reject $description', ({ css }) => {
|
||||
expect(() => validateThemeCss(css)).toThrow(
|
||||
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -793,6 +779,12 @@ describe('validateThemeCss', () => {
|
||||
--spacing: 10px 20px;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
description: 'value with comma-separated values',
|
||||
css: `:root {
|
||||
--font-family: Arial, sans-serif;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
description: 'property name with invalid characters',
|
||||
css: `:root {
|
||||
@@ -876,337 +868,6 @@ 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('valid theme JSON', () => {
|
||||
it('should parse valid theme with all required fields', () => {
|
||||
@@ -1472,123 +1133,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,63 +79,6 @@ export async function fetchDirectCss(url: string): Promise<string> {
|
||||
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). */
|
||||
const VAR_ONLY_PATTERN = /^var\s*\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/i;
|
||||
|
||||
@@ -149,15 +92,8 @@ function isValidSimpleVarValue(value: string): boolean {
|
||||
/**
|
||||
* 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).
|
||||
* Font properties (--font-*) are validated against a safe font family allowlist instead.
|
||||
*/
|
||||
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) {
|
||||
return; // Empty values are allowed
|
||||
}
|
||||
@@ -209,152 +145,79 @@ 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;
|
||||
|
||||
/**
|
||||
* Extract @font-face blocks from CSS. Returns the blocks and the remaining CSS.
|
||||
* Only matches top-level @font-face blocks (not nested inside other rules).
|
||||
* Validate that CSS contains only :root { ... } with CSS custom property (variable) declarations.
|
||||
* Must contain exactly :root { ... } and nothing else.
|
||||
* Returns the validated CSS or throws an error.
|
||||
*/
|
||||
function extractFontFaceBlocks(css: string): {
|
||||
fontFaceBlocks: string[];
|
||||
remaining: string;
|
||||
} {
|
||||
const fontFaceBlocks: string[] = [];
|
||||
let remaining = css;
|
||||
export function validateThemeCss(css: string): string {
|
||||
// Strip multi-line comments before validation
|
||||
// Note: Single-line comments (//) are not stripped to avoid corrupting CSS values like URLs
|
||||
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||
|
||||
// Extract @font-face { ... } blocks one at a time using indexOf-based
|
||||
// parsing. Each iteration removes the matched block from `remaining`.
|
||||
for (;;) {
|
||||
const atIdx = remaining.indexOf('@font-face');
|
||||
if (atIdx === -1) break;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return { fontFaceBlocks, remaining: remaining.trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate @font-face blocks: only data: URIs allowed (no remote URLs).
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSize > MAX_TOTAL_FONT_SIZE) {
|
||||
// Must contain exactly :root { ... } and nothing else
|
||||
// Find :root { ... } and extract content, then check there's nothing after
|
||||
const rootMatch = cleaned.match(/^:root\s*\{/);
|
||||
if (!rootMatch) {
|
||||
throw new Error(
|
||||
`Total embedded font data exceeds maximum of ${MAX_TOTAL_FONT_SIZE / 1024 / 1024}MB.`,
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
// Find the opening brace after :root
|
||||
const rootStart = cleaned.indexOf(':root');
|
||||
const openBrace = cleaned.indexOf('{', rootStart);
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const ch = content[i];
|
||||
|
||||
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;
|
||||
}
|
||||
if (openBrace === -1) {
|
||||
throw new Error(
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
const trimmed = content.substring(start).trim();
|
||||
if (trimmed) declarations.push(trimmed);
|
||||
// Find the first closing brace (nested blocks will be caught by the check below)
|
||||
const closeBrace = cleaned.indexOf('}', openBrace + 1);
|
||||
|
||||
return declarations;
|
||||
}
|
||||
if (closeBrace === -1) {
|
||||
throw new Error(
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── :root block validation ─────────────────────────────────────────────────
|
||||
// Extract content inside :root { ... }
|
||||
const rootContent = cleaned.substring(openBrace + 1, closeBrace).trim();
|
||||
|
||||
/**
|
||||
* 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
|
||||
// Check for forbidden at-rules first (before nested block check, since at-rules with braces would trigger that)
|
||||
// Comprehensive list of CSS at-rules that should not be allowed
|
||||
// This includes @import, @media, @keyframes, @font-face, @supports, @charset,
|
||||
// @namespace, @page, @layer, @container, @scope, and any other at-rules
|
||||
if (/@[a-z-]+/i.test(rootContent)) {
|
||||
throw new Error(
|
||||
'Theme CSS contains forbidden at-rules (@import, @media, @keyframes, etc.). Only CSS variable declarations are allowed inside :root { ... }.',
|
||||
);
|
||||
}
|
||||
|
||||
// Check for nested blocks
|
||||
// Check for nested blocks (additional selectors) - should not have any { after extracting :root content
|
||||
if (/\{/.test(rootContent)) {
|
||||
throw new Error(
|
||||
'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.',
|
||||
);
|
||||
}
|
||||
|
||||
for (const decl of splitDeclarations(rootContent)) {
|
||||
// Check that there's nothing after the closing brace
|
||||
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(':');
|
||||
if (colonIndex === -1) {
|
||||
throw new Error(`Invalid CSS declaration: "${decl}"`);
|
||||
@@ -408,220 +271,9 @@ function validateRootContent(rootContent: string): void {
|
||||
const value = decl.substring(colonIndex + 1).trim();
|
||||
validatePropertyValue(value, property);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main validation entry point ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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;
|
||||
// Return the original CSS (with :root wrapper) so it can be injected properly
|
||||
return css.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,10 +128,6 @@ app.get('/metrics', (_req, res) => {
|
||||
app.use((req, res, next) => {
|
||||
res.set('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
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();
|
||||
});
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Custom Themes: allow using a custom font
|
||||
Reference in New Issue
Block a user