Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc637c0bd9
|
||
|
|
f1e10f173d
|
||
|
|
76b14c0d17
|
||
|
|
97e8dd6bf5
|
||
|
|
7a8a9f395e
|
||
|
|
4749020bc3
|
||
|
|
3dea524dd9
|
||
|
|
1c02e62b2a
|
||
|
|
df2bf37987
|
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
@@ -12,17 +12,50 @@ 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)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -60,10 +93,32 @@ 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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,7 +3,7 @@
|
||||
# name: hledger
|
||||
# about: Render hledger journals in topics
|
||||
# meta_topic_id: TODO
|
||||
# version: 1.0.0
|
||||
# version: 1.1.0
|
||||
# authors: Râu Cao
|
||||
# url: https://gitea.kosmos.org/raucao/discourse-hledger
|
||||
# required_version: 2.7.0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user