12 Commits
Author SHA1 Message Date
raucao aeafc4c54a Add screenshots
CI / lint (push) Successful in 1m48s
CI / tests (push) Successful in 2m56s
2026-09-27 17:45:13 +02:00
raucao 514385a38f Add script for taking screenshots of rendered journals 2026-09-27 17:44:50 +02:00
raucao 87d9c75dc1 Fix plugin name to match repo name, convention
CI / lint (push) Successful in 1m33s
CI / tests (push) Successful in 7m33s
2026-09-27 17:12:40 +02:00
raucao dc637c0bd9 Prepare the 1.1.0 release
CI / lint (push) Successful in 1m41s
CI / tests (push) Successful in 2m48s
2026-09-27 16:17:51 +02:00
raucao f1e10f173d Document Docker installation and security hardening 2026-09-27 16:09:30 +02:00
raucao 76b14c0d17 Avoid duplicate and stale report requests 2026-09-27 16:09:24 +02:00
raucao 97e8dd6bf5 Document equity's absolute-balance behavior 2026-09-27 16:09:14 +02:00
raucao 7a8a9f395e Make the journal line limit configurable 2026-09-27 16:08:59 +02:00
raucao 4749020bc3 Cache reports by hledger version 2026-09-27 16:08:48 +02:00
raucao 3dea524dd9 Reject more include directives and invalid journal encodings 2026-09-27 16:08:39 +02:00
raucao 1c02e62b2a Add a global rate limit for report generation 2026-09-27 16:08:06 +02:00
raucao df2bf37987 Harden the hledger subprocess and report failures 2026-09-27 16:07:31 +02:00
27 changed files with 786 additions and 65 deletions
+4 -4
View File
@@ -57,7 +57,7 @@ jobs:
run: git clone --depth 1 --branch latest https://github.com/discourse/discourse.git core
- name: Install plugin
run: cp -r plugin core/plugins/hledger
run: cp -r plugin core/plugins/discourse-hledger
- name: Container envs
id: container-envs
@@ -91,7 +91,7 @@ jobs:
uses: actions/cache@v4
with:
path: core/tmp/app-cache
key: ${{ runner.os }}-app-v1-${{ hashFiles('core/db/**/*', 'core/plugins/hledger/db/**/*', 'plugin/.gitea/workflows/ci.yml') }}
key: ${{ runner.os }}-app-v1-${{ hashFiles('core/db/**/*', 'core/plugins/discourse-hledger/db/**/*', 'plugin/.gitea/workflows/ci.yml') }}
- name: Install hledger
run: |
@@ -140,10 +140,10 @@ jobs:
- name: Plugin RSpec
working-directory: core
run: bin/rspec plugins/hledger/spec/lib plugins/hledger/spec/requests
run: bin/rspec plugins/discourse-hledger/spec/lib plugins/discourse-hledger/spec/requests
- name: Plugin QUnit
working-directory: core
env:
DISCOURSE_DISABLE_BROWSER_SANDBOX: "1"
run: bin/rake plugin:qunit['hledger']
run: bin/rake plugin:qunit['discourse-hledger']
+69 -1
View File
@@ -1,5 +1,73 @@
# Changelog
## 1.2.0 — 2026-09-27
Fixes the plugin's identity so Discourse associates its site settings and admin
UI correctly. The plugin's metadata name was `hledger` while the repository (and
therefore the usual install directory) is named `discourse-hledger`. When those
differ, Discourse loads the plugin but does not attach its settings to it, so
the plugin appeared in the admin Plugins list without a Settings button and
without an entry in the admin sidebar.
### Installation
The plugin is named `discourse-hledger` throughout now. Clone and install it
into `plugins/discourse-hledger`; the directory name must match the plugin's
`# name:`. The settings category is now `discourse_hledger`; the individual
setting names are unchanged (`hledger_*`), so existing configuration carries
over.
## 1.1.0 — 2026-09-27
Hardening and operational improvements on top of the first release. The report
surface is unchanged; this release tightens how untrusted journals are executed
and gives administrators more control over resource use.
### Sandbox
hledger now also runs under a memory cap (`RLIMIT_DATA`) and a file-size limit,
and core dumps are disabled. The child's `PATH` is reduced to the directory of
the resolved executable. When hledger produces more output than the configured
cap, the plugin reports a distinct "report too large" error instead of failing
later while parsing truncated JSON.
### Rate limiting
A global rate limit (`hledger global rate limit per minute`, default 120) now
complements the existing per-IP limit, bounding report generation across all
users even when requests are spread over many IPs. Set it to 0 to disable it.
### Journal validation
Directive rejection now also covers the `! include` spelling, and journals with
invalid encoding are reported as such instead of reaching hledger. The line
limit is configurable (`hledger max journal lines`, default 5000).
### Caching and logging
Cached reports are keyed on the detected hledger version as well as the post
revision, so an in-place hledger upgrade no longer serves stale results. Failed
executions are logged server-side with the report type, topic and user plus a
truncated stderr excerpt, without logging the journal itself.
### Frontend
The dashboard no longer issues duplicate requests for the same report and
parameters, and discards responses from superseded requests when reports or
filters change quickly.
### Equity
The equity distribution is documented and tested as based on absolute equity
balances, and the in-app note states this explicitly.
### Documentation
The README now covers the recommended default Docker deployment (installing the
plugin and `hledger` via the `app.yml` `after_code` hook), the layered security
model, an OS/container sandbox recommendation for public instances, and the
relevant site settings.
## 1.0.0 — 2026-09-27
The first release of the hledger plugin for Discourse. Put an
@@ -58,4 +126,4 @@ per minute per IP, and results are cached per post revision.
### Requirements
- The `hledger` executable on the server.
- Discourse 2.7.0 or newer.
- Discourse 2.7.0 or newer.
+77 -14
View File
@@ -12,17 +12,58 @@ by the `hledger` command line tool:
- 📋 Accounts and balances (a full account tree, each name capitalized)
- ⚖️ Balance sheet (indented account tree under each section)
- 📈 Profit & loss / P&L (indented account tree under each section)
- 👥 Equity distribution (account tree with per-commodity shares; contributed
capital, not legal ownership)
- 👥 Equity distribution (account tree with per-commodity shares from absolute
balances; contributed capital, not legal ownership)
- 🧾 Journal (a detailed transaction log: date, description and postings)
## Screenshots
Rendered from the [example journal](docs/example.journal):
| Accounts | P&L | Journal |
| - | - | - |
| [![Accounts](docs/screenshots/accounts.png)](docs/screenshots/accounts.png) | [![P&L](docs/screenshots/pnl.png)](docs/screenshots/pnl.png) | [![Journal](docs/screenshots/journal.png)](docs/screenshots/journal.png) |
## Installation
Follow the [plugin installation guide](https://meta.discourse.org/t/install-a-plugin/19157).
The plugin requires the `hledger` executable on the server.
The plugin requires the `hledger` executable on the server. Install it with
your package manager (for example `apt-get install hledger`) and point the
`hledger path` site setting at it if it is not on the default `PATH`.
### Default Docker deployment
The recommended self-hosted setup runs Discourse in a container built from an
`app.yml` file ([install guide](https://github.com/discourse/discourse/blob/main/docs/INSTALL-cloud.md)).
Add the plugin and the `hledger` package to `/var/discourse/containers/app.yml`:
```yaml
hooks:
after_code:
- exec:
cd: $home/plugins
cmd:
- git clone https://gitea.kosmos.org/raucao/discourse-hledger.git
- exec:
cd: $home
cmd:
- apt-get update
- DEBIAN_FRONTEND=noninteractive apt-get install -y hledger
```
Then rebuild the container to apply the changes:
```sh
cd /var/discourse
./launcher rebuild app
```
The `after_code` hook runs on every rebuild, so the plugin and `hledger` stay
installed across rebuilds.
### Other installs
Follow the [plugin installation guide](https://meta.discourse.org/t/install-a-plugin/19157).
Install `hledger` with your package manager (for example
`apt-get install hledger`) and point the `hledger path` site setting at it if it
is not on the default `PATH`.
### Docker development environment
@@ -30,7 +71,7 @@ Inside the Discourse dev container, install the plugin's system dependencies
(hledger, Chromium and the Playwright browser used by the test suite) with:
```sh
docker exec -u root discourse_dev bash /src/plugins/hledger/bin/setup-dev
docker exec -u root discourse_dev bash /src/plugins/discourse-hledger/bin/setup-dev
```
## How to use
@@ -60,20 +101,42 @@ are linked; anything else stays plain text.
## Security
Journals are untrusted input. The plugin runs `hledger` with a scrubbed
environment, a private working directory, a hard timeout, resource limits and
an output cap, and it rejects `include` directives so a journal cannot read
arbitrary server files.
Journals are untrusted input. The plugin runs `hledger` as an unprivileged
subprocess with a scrubbed environment, a private working directory, a hard
timeout, CPU, file-size and memory limits and an output cap, and it rejects
`include` directives so a journal cannot read arbitrary server files.
Defenses are layered: Discourse authorization, a per-IP rate limit and the
global rate limit, the journal size and line limits, the execution timeout, the
resource limits and the output cap.
For installations where untrusted users can create public topics, additionally
sandbox `hledger` at the OS or container level — for example a systemd unit with
`ProtectSystem=strict`, `ProtectHome=true`, `PrivateTmp=true`,
`NoNewPrivileges=true`, `RestrictAddressFamilies=AF_UNIX`, `MemoryMax=` and
`TasksMax=`, or a dedicated container runtime. Those controls are stronger than
in-process resource limits.
### Relevant settings
- `hledger max journal bytes` and `hledger max journal lines` limit the size of
an accepted journal.
- `hledger timeout seconds` limits the wall-clock time of each `hledger`
invocation.
- `hledger memory limit mb` caps the process memory (`0` disables it).
- `hledger max output bytes` caps the captured output.
- `hledger global rate limit per minute` throttles report generation across all
users (`0` disables it).
## Development
```sh
# Ruby specs (run `bin/setup-dev` first for the integration specs)
bin/rspec plugins/hledger/spec/lib plugins/hledger/spec/requests
bin/rspec plugins/hledger/spec/system
bin/rspec plugins/discourse-hledger/spec/lib plugins/discourse-hledger/spec/requests
bin/rspec plugins/discourse-hledger/spec/system
# Frontend tests
DISCOURSE_DISABLE_BROWSER_SANDBOX=1 bin/qunit plugins/hledger/test/javascripts
DISCOURSE_DISABLE_BROWSER_SANDBOX=1 bin/qunit plugins/discourse-hledger/test/javascripts
```
## License
+28 -5
View File
@@ -11,6 +11,7 @@ module ::Hledger
before_action :ensure_enabled
before_action :find_topic
before_action :enforce_rate_limit
before_action :enforce_global_rate_limit
def show
report_type = params[:report_type].to_s
@@ -27,7 +28,8 @@ module ::Hledger
end
first_post = @topic.first_post
cache_key = cache_key_for(first_post, report_type, begin_date, end_date)
runner = Hledger::Runner.new
cache_key = cache_key_for(runner, first_post, report_type, begin_date, end_date)
if (cached = Discourse.cache.read(cache_key))
return render(json: cached)
@@ -40,7 +42,7 @@ module ::Hledger
begin_date: begin_date,
end_date: end_date,
},
runner: Hledger::Runner.new,
runner: runner,
) do
on_success do |report:|
Discourse.cache.write(cache_key, report, expires_in: CACHE_TTL)
@@ -50,10 +52,16 @@ module ::Hledger
on_failed_contract do |contract|
render_json_error(contract.errors.full_messages, status: 422)
end
on_exceptions(Hledger::UnavailableError) do
on_exceptions(Hledger::UnavailableError) do |error|
log_failure(error)
render_json_error(I18n.t("hledger.errors.unavailable"), status: 503)
end
on_exceptions(Hledger::Error) do
on_exceptions(Hledger::OutputLimitError) do |error|
log_failure(error)
render_json_error(I18n.t("hledger.errors.output_too_large"), status: 422)
end
on_exceptions(Hledger::Error) do |error|
log_failure(error)
render_json_error(I18n.t("hledger.errors.execution"), status: 422)
end
on_failure { render_json_error(I18n.t("hledger.errors.generic"), status: 422) }
@@ -81,6 +89,13 @@ module ::Hledger
).performed!
end
def enforce_global_rate_limit
max = SiteSetting.hledger_global_rate_limit_per_minute.to_i
return if max <= 0
RateLimiter.new(nil, "hledger-reports-global", max, 60, global: true).performed!
end
def parse_date(value)
return nil if value.blank?
@@ -89,7 +104,7 @@ module ::Hledger
:invalid
end
def cache_key_for(first_post, report_type, begin_date, end_date)
def cache_key_for(runner, first_post, report_type, begin_date, end_date)
[
"hledger",
@topic.id,
@@ -98,6 +113,7 @@ module ::Hledger
begin_date,
end_date,
SiteSetting.hledger_path,
runner.version,
].join(":")
end
@@ -107,5 +123,12 @@ module ::Hledger
render_json_error(I18n.t(key), status: error == :none ? 404 : 422)
end
def log_failure(error)
Rails.logger.warn(
"hledger report failed: report=#{params[:report_type]} topic=#{@topic&.id} " \
"user=#{current_user&.id} error=#{error.class} detail=#{error.message.to_s.truncate(500)}",
)
end
end
end
@@ -28,6 +28,10 @@ export default class HledgerDashboard extends Component {
@tracked beginDate = null;
@tracked endDate = null;
#inflight = null;
#lastKey = null;
#requestId = 0;
constructor() {
super(...arguments);
this.loadReport();
@@ -103,6 +107,16 @@ export default class HledgerDashboard extends Component {
return;
}
const key = `${this.selectedReport}|${this.beginDate}|${this.endDate}`;
if (key === this.#lastKey && !this.error) {
return;
}
this.#lastKey = key;
const requestId = ++this.#requestId;
this.#inflight?.abort();
this.loading = true;
this.error = null;
@@ -115,16 +129,27 @@ export default class HledgerDashboard extends Component {
}
try {
this.report = await ajax(
const promise = ajax(
`/hledger/topics/${topicId}/reports/${this.selectedReport}`,
{ data }
);
this.#inflight = promise;
const report = await promise;
if (requestId === this.#requestId) {
this.report = report;
}
} catch (e) {
this.report = null;
this.error =
e?.jqXHR?.responseJSON?.errors?.[0] || i18n("hledger.errors.generic");
if (requestId === this.#requestId) {
this.report = null;
this.error =
e?.jqXHR?.responseJSON?.errors?.[0] || i18n("hledger.errors.generic");
}
} finally {
this.loading = false;
if (requestId === this.#requestId) {
this.loading = false;
this.#inflight = null;
}
}
}
+2 -2
View File
@@ -2,11 +2,11 @@
# Install the system dependencies needed to develop and test the hledger plugin.
#
# Run inside the dev container as root, for example:
# docker exec -u root discourse_dev bash /src/plugins/hledger/bin/setup-dev
# docker exec -u root discourse_dev bash /src/plugins/discourse-hledger/bin/setup-dev
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Run as root inside the dev container, eg: docker exec -u root discourse_dev bash /src/plugins/hledger/bin/setup-dev" >&2
echo "Run as root inside the dev container, eg: docker exec -u root discourse_dev bash /src/plugins/discourse-hledger/bin/setup-dev" >&2
exit 1
fi
+2 -2
View File
@@ -3,7 +3,7 @@ en:
admin:
site_settings:
categories:
hledger: "Hledger"
discourse_hledger: "Hledger"
js:
hledger:
reports:
@@ -22,6 +22,6 @@ en:
holder: "Holder"
amount: "Amount"
share: "Share"
note: "Contributed-capital distribution, not legal ownership."
note: "Based on absolute equity balances: a contributed-capital distribution, not legal ownership."
errors:
generic: "Could not load the report."
+4
View File
@@ -3,14 +3,18 @@ en:
hledger_enabled: "Enable the hledger plugin"
hledger_path: "Path to the hledger executable"
hledger_max_journal_bytes: "Maximum size of an hledger journal in bytes"
hledger_max_journal_lines: "Maximum number of lines in an hledger journal"
hledger_timeout_seconds: "Maximum number of seconds an hledger command may run"
hledger_max_output_bytes: "Maximum number of bytes captured from hledger output"
hledger_memory_limit_mb: "Maximum memory in megabytes the hledger process may use; set to 0 to disable the limit"
hledger_global_rate_limit_per_minute: "Maximum number of hledger reports that may be generated per minute across all users; set to 0 to disable the limit"
hledger:
errors:
unsupported_report: "Unsupported report type."
invalid_date: "Invalid date; expected YYYY-MM-DD."
unavailable: "hledger is not available on this server."
execution: "hledger could not generate the report."
output_too_large: "The report is too large to generate."
generic: "The report could not be generated."
journal:
none: "No hledger journal was found in this topic."
+10 -1
View File
@@ -1,4 +1,4 @@
hledger:
discourse_hledger:
hledger_enabled:
default: false
client: true
@@ -6,7 +6,16 @@ hledger:
default: hledger
hledger_max_journal_bytes:
default: 262144
hledger_max_journal_lines:
default: 5000
min: 1
hledger_timeout_seconds:
default: 10
hledger_max_output_bytes:
default: 1048576
hledger_memory_limit_mb:
default: 256
min: 0
hledger_global_rate_limit_per_minute:
default: 120
min: 0
+36
View File
@@ -0,0 +1,36 @@
; Example journal for the Discourse hledger plugin.
; A community collective with member contributions, dues, donations and costs.
2024-01-01 Opening balances
assets:bank:checking 2500.00 USD
assets:bank:savings 5000.00 USD
equity:members:@alice -4500.00 USD
equity:members:@bob -3000.00 USD
2024-01-15 Membership dues, Q1
assets:bank:checking 300.00 USD
revenues:dues
2024-01-20 Interest-free member loan, @carol
assets:bank:checking 500.00 USD
liabilities:loans:@carol
2024-02-03 Donation from @carol
assets:bank:checking 150.00 USD
revenues:donations
2024-02-20 Server hosting, February
expenses:hosting 40.00 USD
assets:bank:checking
2024-03-05 Podcast microphone
expenses:equipment 120.00 USD
assets:bank:checking
2024-03-22 Travel reimbursement, @alice
expenses:travel 180.00 USD
assets:bank:checking
2024-04-02 Transfer to savings
assets:bank:savings 1000.00 USD
assets:bank:checking
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+4 -3
View File
@@ -6,8 +6,7 @@ module Hledger
# control.
class Journal
FENCE = /^[ \t]*`{3,}hledger[ \t]*\r?\n(?<source>.*?)^[ \t]*`{3,}[ \t]*$/m
INCLUDE = /^[ \t]*!?include\b/i
MAX_LINES = 5_000
INCLUDE = /^[ \t]*!?[ \t]*include\b/i
Result =
Struct.new(:source, :error, keyword_init: true) do
@@ -29,6 +28,8 @@ module Hledger
end
def extract
return Result.new(source: nil, error: :invalid_encoding) unless @raw.valid_encoding?
matches = @raw.scan(FENCE).flatten
return Result.new(source: nil, error: :none) if matches.empty?
return Result.new(source: nil, error: :ambiguous) if matches.length > 1
@@ -44,7 +45,7 @@ module Hledger
def validate(source)
return :too_large if source.bytesize > SiteSetting.hledger_max_journal_bytes
return :too_many_lines if source.lines.size > MAX_LINES
return :too_many_lines if source.lines.size > SiteSetting.hledger_max_journal_lines
return :invalid_encoding unless source.valid_encoding?
return :include_not_allowed if source.match?(INCLUDE)
+8
View File
@@ -0,0 +1,8 @@
# frozen_string_literal: true
module Hledger
# Raised when hledger produced more output than the configured cap, which
# means any captured JSON would be truncated.
class OutputLimitError < Error
end
end
+5 -2
View File
@@ -152,8 +152,11 @@ module Hledger
def run(argv, journal)
result = @runner.execute([*base_flags, *argv], stdin_data: journal)
raise ExecutionError, result.stderr if result.timed_out
raise ExecutionError, result.stderr unless result.success?
raise OutputLimitError if result.output_limit_exceeded
raise ExecutionError, "hledger timed out: #{result.stderr}" if result.timed_out
unless result.success?
raise ExecutionError, "hledger failed (exit #{result.exit_status}): #{result.stderr}"
end
result
end
+58 -10
View File
@@ -10,18 +10,28 @@ module Hledger
# a hard wall-clock timeout.
class Runner
Result =
Struct.new(:success, :stdout, :stderr, :exit_status, :timed_out, keyword_init: true) do
Struct.new(
:success,
:stdout,
:stderr,
:exit_status,
:timed_out,
:output_limit_exceeded,
keyword_init: true,
) do
def success?
success
end
end
READ_CHUNK = 16_384
MAX_FILE_SIZE = 8 * 1024 * 1024
def initialize(path: nil, timeout: nil, max_output: nil)
def initialize(path: nil, timeout: nil, max_output: nil, memory_limit: nil)
@path = (path || SiteSetting.hledger_path).to_s
@timeout = (timeout || SiteSetting.hledger_timeout_seconds).to_i
@max_output = (max_output || SiteSetting.hledger_max_output_bytes).to_i
@memory_limit_mb = (memory_limit || SiteSetting.hledger_memory_limit_mb).to_i
end
def available?
@@ -31,7 +41,7 @@ module Hledger
def version
return @version if defined?(@version)
@version = detect_version
@version = detect_version_cached
end
def execute(argv, stdin_data: nil)
@@ -46,7 +56,7 @@ module Hledger
def run(binary, argv, stdin_data)
tmpdir = Dir.mktmpdir("hledger-")
env = {
"PATH" => ENV["PATH"].to_s,
"PATH" => File.dirname(binary),
"HOME" => tmpdir,
"TMPDIR" => tmpdir,
"XDG_CACHE_HOME" => tmpdir,
@@ -61,12 +71,19 @@ module Hledger
chdir: tmpdir,
rlimit_cpu: @timeout + 5,
rlimit_nofile: 64,
rlimit_fsize: MAX_FILE_SIZE,
rlimit_core: 0,
}
# hledger is a GHC binary, so cap memory via RLIMIT_DATA (which since
# Linux 4.7 also covers mmap). The GHC runtime options distro packages
# ship with are disabled ("Most RTS options are disabled").
spawn_opts[:rlimit_data] = @memory_limit_mb * 1024 * 1024 if @memory_limit_mb.positive?
status = nil
timed_out = false
stdout = +""
stderr = +""
output_limit_exceeded = false
Open3.popen3(env, binary, *argv, **spawn_opts) do |stdin, out, err, wait_thr|
writer = Thread.new { write_stdin(stdin, stdin_data) }
@@ -82,8 +99,9 @@ module Hledger
end
writer.join(1)
stdout = out_reader.value
stderr = err_reader.value
stdout, stdout_truncated = out_reader.value
stderr, stderr_truncated = err_reader.value
output_limit_exceeded = stdout_truncated || stderr_truncated
end
Result.new(
@@ -92,6 +110,7 @@ module Hledger
stderr: stderr,
exit_status: status&.exitstatus,
timed_out: timed_out,
output_limit_exceeded: output_limit_exceeded,
)
rescue SystemCallError
failure_result
@@ -107,15 +126,23 @@ module Hledger
def read_capped(io)
buffer = +""
truncated = false
while (chunk = io.read(READ_CHUNK))
break if chunk.empty?
remaining = @max_output - buffer.bytesize
buffer << chunk.byteslice(0, remaining) if remaining.positive?
if remaining <= 0
truncated = true
elsif chunk.bytesize <= remaining
buffer << chunk
else
buffer << chunk.byteslice(0, remaining)
truncated = true
end
end
buffer
[buffer, truncated]
rescue IOError, Errno::EPIPE
buffer
[buffer, truncated]
ensure
io.close
end
@@ -156,8 +183,29 @@ module Hledger
result.stdout[/\d+\.\d+(?:\.\d+)?/]
end
# The version is part of the report cache key, so remember it to avoid a
# `--version` subprocess on every request.
def detect_version_cached
return detect_version if @path.blank?
key = "hledger:version:#{@path}"
cached = Discourse.cache.read(key)
return cached if cached
detected = detect_version
Discourse.cache.write(key, detected, expires_in: 10.minutes) if detected
detected
end
def failure_result
Result.new(success: false, stdout: "", stderr: "", exit_status: nil, timed_out: false)
Result.new(
success: false,
stdout: "",
stderr: "",
exit_status: nil,
timed_out: false,
output_limit_exceeded: false,
)
end
end
end
+3 -3
View File
@@ -1,9 +1,9 @@
# frozen_string_literal: true
# name: hledger
# name: discourse-hledger
# about: Render hledger journals in topics
# meta_topic_id: TODO
# version: 1.0.0
# version: 1.2.0
# authors: Râu Cao
# url: https://gitea.kosmos.org/raucao/discourse-hledger
# required_version: 2.7.0
@@ -17,7 +17,7 @@ register_svg_icon "receipt"
enabled_site_setting :hledger_enabled
module ::Hledger
PLUGIN_NAME = "hledger"
PLUGIN_NAME = "discourse-hledger"
end
require_relative "lib/hledger/engine"
+3 -1
View File
@@ -4,7 +4,9 @@
RSpec.describe "Hledger integration" do
before { skip "hledger is not installed" if !Hledger::Runner.new.available? }
let(:journal) { File.read(Rails.root.join("plugins/hledger/spec/fixtures/journal.txt")) }
let(:journal) do
File.read(Rails.root.join("plugins/discourse-hledger/spec/fixtures/journal.txt"))
end
let(:builder) { Hledger::ReportBuilder.new }
it "builds every supported report" do
+53
View File
@@ -39,6 +39,32 @@ RSpec.describe Hledger::Journal do
expect(result.error).to eq(:include_not_allowed)
end
[
"include /etc/passwd",
"include ../foo",
"include ~/foo",
"include *.journal",
"include **/*",
" include foo",
"\tinclude foo",
"INCLUDE foo",
"!include foo",
"! include foo",
"! include foo",
].each do |directive|
it "rejects the include directive #{directive.inspect}" do
result = described_class.extract(raw_with(directive))
expect(result.error).to eq(:include_not_allowed)
end
end
it "allows the word include inside a transaction" do
result = described_class.extract(raw_with("2024-01-01 include the foo\n a 1\n b -1"))
expect(result).to be_ok
end
it "rejects oversized journals" do
SiteSetting.hledger_max_journal_bytes = 10
@@ -48,4 +74,31 @@ RSpec.describe Hledger::Journal do
ensure
SiteSetting.hledger_max_journal_bytes = 262_144
end
it "rejects journals with too many lines" do
SiteSetting.hledger_max_journal_lines = 2
result = described_class.extract(raw_with("a\nb\nc"))
expect(result.error).to eq(:too_many_lines)
ensure
SiteSetting.hledger_max_journal_lines = 5000
end
it "extracts a fence with trailing whitespace and a longer fence" do
raw = "Intro\n\n````hledger \n2024-01-01 x\n a 1\n b -1\n````\n\nOutro\n"
result = described_class.extract(raw)
expect(result).to be_ok
expect(result.source).to include("2024-01-01 x")
end
it "rejects invalid UTF-8" do
raw = "```hledger\n2024-01-01 \xFF\n```\n"
result = described_class.extract(raw)
expect(result.error).to eq(:invalid_encoding)
end
end
+82 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class FakeHledgerRunner
FIXTURES = Rails.root.join("plugins/hledger/spec/fixtures")
FIXTURES = Rails.root.join("plugins/discourse-hledger/spec/fixtures")
FILES = {
"accounts" => "accounts.json",
"balance_sheet" => "balance_sheet.json",
@@ -43,6 +43,25 @@ end
RSpec.describe Hledger::ReportBuilder do
subject(:builder) { described_class.new(runner: FakeHledgerRunner.new) }
def equity_amount(mantissa, places: 2)
{
"acommodity" => "EUR",
"acost" => nil,
"acostbasis" => nil,
"aquantity" => {
"decimalMantissa" => mantissa,
"decimalPlaces" => places,
"floatingPoint" => mantissa / 100.0,
},
"astyle" => {
"ascommodityside" => "R",
"ascommodityspaced" => true,
"asdecimalmark" => ".",
"asprecision" => 2,
},
}
end
describe "accounts" do
it "returns the full account tree with aggregated totals" do
report = builder.build("accounts", journal: "journal")
@@ -113,6 +132,50 @@ RSpec.describe Hledger::ReportBuilder do
expect(group["rows"].map { |row| row["percentage"] }).to eq(%w[60.00 40.00])
expect(group["rows"].first["amounts"].first["display"]).to eq("600.00 EUR")
end
it "uses absolute balances for the distribution" do
runner = FakeHledgerRunner.new
rows = [
["equity:alice", "equity:alice", 0, [equity_amount(-10_000)]],
["equity:bob", "equity:bob", 0, [equity_amount(2_000)]],
]
allow(runner).to receive(:execute).and_return(
Hledger::Runner::Result.new(
success: true,
stdout: [rows].to_json,
stderr: "",
exit_status: 0,
timed_out: false,
output_limit_exceeded: false,
),
)
report = described_class.new(runner: runner).build("equity", journal: "journal")
group = report["groups"].first
expect(group["total"]["quantity"]).to eq("120.0")
expect(group["rows"].map { |row| row["percentage"] }).to eq(%w[83.33 16.67])
expect(group["rows"].first["amounts"].first["quantity"]).to eq("100.0")
end
it "does not divide by zero when the total is zero" do
runner = FakeHledgerRunner.new
rows = [["equity:alice", "equity:alice", 0, [equity_amount(0)]]]
allow(runner).to receive(:execute).and_return(
Hledger::Runner::Result.new(
success: true,
stdout: [rows].to_json,
stderr: "",
exit_status: 0,
timed_out: false,
output_limit_exceeded: false,
),
)
report = described_class.new(runner: runner).build("equity", journal: "journal")
expect(report["groups"].first["rows"].first["percentage"]).to eq("0.00")
end
end
describe "transactions" do
@@ -159,4 +222,22 @@ RSpec.describe Hledger::ReportBuilder do
described_class.new(runner: unavailable).build("accounts", journal: "x")
}.to raise_error(Hledger::UnavailableError)
end
it "raises when the captured output was truncated" do
runner = FakeHledgerRunner.new
allow(runner).to receive(:execute).and_return(
Hledger::Runner::Result.new(
success: true,
stdout: "",
stderr: "",
exit_status: 0,
timed_out: false,
output_limit_exceeded: true,
),
)
expect { described_class.new(runner: runner).build("accounts", journal: "x") }.to raise_error(
Hledger::OutputLimitError,
)
end
end
+41 -4
View File
@@ -1,8 +1,13 @@
# frozen_string_literal: true
RSpec.describe Hledger::Runner do
def runner(path:, timeout: 5, max_output: 1_000)
described_class.new(path: path, timeout: timeout, max_output: max_output)
def runner(path:, timeout: 5, max_output: 1_000, memory_limit: 0)
described_class.new(
path: path,
timeout: timeout,
max_output: max_output,
memory_limit: memory_limit,
)
end
it "captures stdout and exit status" do
@@ -25,9 +30,41 @@ RSpec.describe Hledger::Runner do
end
it "caps captured output" do
result = runner(path: "/bin/sh", max_output: 10).execute(["-c", "printf '%0.sa' {1..100}"])
result =
runner(path: "/bin/sh", max_output: 10).execute(["-c", "printf 'aaaaaaaaaaaaaaaaaaaa'"])
expect(result.stdout.bytesize).to be <= 10
expect(result.stdout.bytesize).to eq(10)
end
it "flags truncated output" do
result =
runner(path: "/bin/sh", max_output: 10).execute(["-c", "printf 'aaaaaaaaaaaaaaaaaaaa'"])
expect(result.output_limit_exceeded).to be(true)
end
it "does not flag output within the cap" do
result = runner(path: "/bin/echo").execute(["hello"])
expect(result.output_limit_exceeded).to be(false)
end
it "enforces the memory limit" do
result =
runner(path: RbConfig.ruby, memory_limit: 64).execute(
["-e", "x = 'a' * (200 * 1024 * 1024); puts x.bytesize"],
)
expect(result).not_to be_success
end
it "does not limit memory when set to zero" do
result =
runner(path: RbConfig.ruby, memory_limit: 0).execute(
["-e", "x = 'a' * (200 * 1024 * 1024); puts x.bytesize"],
)
expect(result).to be_success
end
it "returns a failure when the binary is missing" do
+175 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class StubHledgerRunner
FIXTURES = Rails.root.join("plugins/hledger/spec/fixtures")
FIXTURES = Rails.root.join("plugins/discourse-hledger/spec/fixtures")
attr_reader :version
@@ -129,6 +129,25 @@ RSpec.describe Hledger::ReportsController do
expect(response.status).to eq(422)
end
it "includes the hledger version in the cache key" do
get_report("accounts")
expect(response.parsed_body.dig("meta", "hledger_version")).to eq("1.52.1")
allow(Hledger::Runner).to receive(:new).and_return(StubHledgerRunner.new(version: "1.53.0"))
get_report("accounts")
expect(response.parsed_body.dig("meta", "hledger_version")).to eq("1.53.0")
end
it "rejects journals with include directives" do
post.update!(raw: "```hledger\ninclude /etc/passwd\n```")
get_report("accounts")
expect(response.status).to eq(422)
expect(response.body).not_to include("/etc/passwd")
end
it "returns service unavailable when hledger is missing" do
allow(Hledger::Runner).to receive(:new).and_return(StubHledgerRunner.new(version: nil))
@@ -137,6 +156,28 @@ RSpec.describe Hledger::ReportsController do
expect(response.status).to eq(503)
end
it "logs hledger failures without the journal" do
failing = StubHledgerRunner.new
allow(failing).to receive(:execute).and_return(
Hledger::Runner::Result.new(
success: false,
stdout: "",
stderr: "boom",
exit_status: 1,
timed_out: false,
output_limit_exceeded: false,
),
)
allow(Hledger::Runner).to receive(:new).and_return(failing)
allow(Rails.logger).to receive(:warn).and_call_original
get_report("accounts")
expect(response.status).to eq(422)
expect(Rails.logger).to have_received(:warn).with(/hledger report failed.*boom/m)
expect(Rails.logger).not_to have_received(:warn).with(/equity:alice/m)
end
it "hides topics the user cannot see" do
private_category = Fabricate(:private_category, group: Fabricate(:group))
hidden_post = Fabricate(:post, topic: Fabricate(:topic, category: private_category))
@@ -146,6 +187,123 @@ RSpec.describe Hledger::ReportsController do
expect(response.status).to eq(404)
end
describe "authorization" do
def journal_raw
<<~RAW
```hledger
2024-01-01 Opening balances
assets:bank:checking 1000.00 EUR
equity:alice -600.00 EUR
equity:bob -400.00 EUR
```
RAW
end
def report_for(topic)
get "/hledger/topics/#{topic.id}/reports/accounts.json"
end
it "allows anonymous access to a public topic" do
report_for(post.topic)
expect(response.status).to eq(200)
end
it "allows access to a locked and archived topic the user can see" do
post.topic.update!(closed: true, archived: true)
report_for(post.topic)
expect(response.status).to eq(200)
end
it "returns not found for a deleted topic" do
topic = Fabricate(:post, raw: journal_raw).topic
topic.trash!
report_for(topic)
expect(response.status).to eq(404)
expect(response.body).not_to include("assets:bank:checking")
end
it "returns not found when the first post is deleted" do
topic = Fabricate(:post, raw: journal_raw).topic
topic.first_post.trash!
report_for(topic)
expect(response.status).to eq(404)
end
context "with a group-restricted category" do
fab!(:group)
fab!(:restricted_category) { Fabricate(:private_category, group: group) }
fab!(:restricted_topic) do
Fabricate(
:post,
raw: journal_raw,
topic: Fabricate(:topic, category: restricted_category),
).topic
end
it "hides it from anonymous users" do
report_for(restricted_topic)
expect(response.status).to eq(404)
expect(response.body).not_to include("assets:bank:checking")
end
it "hides it from logged in non-members" do
sign_in(Fabricate(:user))
report_for(restricted_topic)
expect(response.status).to eq(404)
end
it "allows members" do
user = Fabricate(:user)
group.add(user)
sign_in(user)
report_for(restricted_topic)
expect(response.status).to eq(200)
end
end
context "with a private message" do
fab!(:member, :user)
fab!(:private_message_post) do
Fabricate(:private_message_post, recipient: member, raw: journal_raw)
end
it "hides it from anonymous users" do
report_for(private_message_post.topic)
expect(response.status).to eq(404)
end
it "hides it from non-members" do
sign_in(Fabricate(:user))
report_for(private_message_post.topic)
expect(response.status).to eq(404)
expect(response.body).not_to include("assets:bank:checking")
end
it "allows members" do
sign_in(member)
report_for(private_message_post.topic)
expect(response.status).to eq(200)
end
end
end
it "is not found when the plugin is disabled" do
SiteSetting.hledger_enabled = false
@@ -153,4 +311,20 @@ RSpec.describe Hledger::ReportsController do
expect(response.status).to eq(404)
end
it "enforces the global rate limit" do
RateLimiter.enable
RateLimiter.clear_all_global!
SiteSetting.hledger_global_rate_limit_per_minute = 1
get_report("accounts")
expect(response.status).to eq(200)
get_report("accounts")
expect(response.status).to eq(429)
ensure
RateLimiter.disable
RateLimiter.clear_all_global!
SiteSetting.hledger_global_rate_limit_per_minute = 120
end
end
+64
View File
@@ -0,0 +1,64 @@
# frozen_string_literal: true
# Captures the screenshots used in the README and announcements. Run through the
# theme screenshot matrix, for example:
#
# TAKE_SCREENSHOTS=1 LOAD_PLUGINS=1 SCREENSHOTS_THEMES=foundation \
# SCREENSHOTS_MODES=light SCREENSHOTS_DEVICES=desktop \
# SCREENSHOTS_SUBSET=hledger \
# bin/rspec spec/system/theme_screenshots_spec.rb
#
# Without TAKE_SCREENSHOTS the spec is skipped, so it does not run in the normal
# system suite.
RSpec.describe "Hledger dashboard screenshots" do
include ThemeScreenshotMarker
let(:journal) { File.read(Rails.root.join("plugins/discourse-hledger/docs/example.journal")) }
fab!(:alice) { Fabricate(:user, username: "alice", name: "Alice") }
fab!(:bob) { Fabricate(:user, username: "bob", name: "Bob") }
fab!(:carol) { Fabricate(:user, username: "carol", name: "Carol") }
before do
skip "Set TAKE_SCREENSHOTS=1 to run this spec" if ENV["TAKE_SCREENSHOTS"] != "1"
SiteSetting.title = "Hledger demo"
SiteSetting.logo = ""
SiteSetting.logo_small = ""
SiteSetting.hledger_enabled = true
end
it "captures every report" do
post = Fabricate(:post, user: alice, raw: "```hledger\n#{journal}\n```")
post.topic.update!(title: "Collective finances 2024")
post.rebake!
visit(post.topic.relative_url)
expect(page).to have_css(".hledger-dashboard__table", text: "Assets")
capture("accounts")
open_report(2)
expect(page).to have_css(".hledger-dashboard__section-title", text: "Assets")
capture("balance-sheet")
open_report(3)
expect(page).to have_css(".hledger-dashboard__section-title", text: "Revenues")
capture("pnl")
open_report(4)
expect(page).to have_css(".hledger-dashboard__note")
capture("equity")
open_report(5)
expect(page).to have_css(".hledger-dashboard__transaction")
capture("journal")
end
def open_report(index)
find(".hledger-dashboard__toolbar .btn:nth-of-type(#{index})").click
end
def capture(label)
screenshot_marker(label: "hledger-#{label}", only: :desktop)
end
end
+28 -6
View File
@@ -260,6 +260,8 @@ const transactionsReport = {
acceptance("Hledger plugin", function (needs) {
needs.settings({ hledger_enabled: true });
const requestCounts = { accounts: 0, equity: 0 };
needs.pretender((server, helper) => {
server.get("/t/45.json", () => {
const topic = cloneJSON(topicFixtures["/t/28830/1.json"]);
@@ -267,12 +269,14 @@ acceptance("Hledger plugin", function (needs) {
return helper.response(topic);
});
server.get(`/hledger/topics/${TOPIC_ID}/reports/accounts`, () =>
helper.response(accountsReport)
);
server.get(`/hledger/topics/${TOPIC_ID}/reports/equity`, () =>
helper.response(equityReport)
);
server.get(`/hledger/topics/${TOPIC_ID}/reports/accounts`, () => {
requestCounts.accounts++;
return helper.response(accountsReport);
});
server.get(`/hledger/topics/${TOPIC_ID}/reports/equity`, () => {
requestCounts.equity++;
return helper.response(equityReport);
});
server.get(`/hledger/topics/${TOPIC_ID}/reports/balance_sheet`, () =>
helper.response(balanceSheetReport)
);
@@ -452,6 +456,24 @@ acceptance("Hledger plugin", function (needs) {
.hasText("Server");
});
test("does not issue duplicate requests for the same report", async function (assert) {
requestCounts.accounts = 0;
requestCounts.equity = 0;
await visit("/t/-/45");
assert.strictEqual(requestCounts.accounts, 1, "loads accounts once");
await click(".hledger-dashboard__toolbar .btn:nth-of-type(4)");
assert.strictEqual(requestCounts.equity, 1, "loads equity once");
await click(".hledger-dashboard__toolbar .btn:nth-of-type(4)");
assert.strictEqual(requestCounts.equity, 1, "ignores the current report");
await click(".hledger-dashboard__toolbar .btn:nth-of-type(1)");
assert.strictEqual(requestCounts.accounts, 2, "reloads on report change");
});
test("shows the transaction log", async function (assert) {
await visit("/t/-/45");