From 85e07c71131d8af8b132602fbc048555cc0897eb Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 29 Jul 2026 23:46:33 +0700 Subject: [PATCH] fix(monkeyd): make /monkeyd_crawl public and fix PDF font resolution Advance the crawler submodule to the fix for the production failure "stat usr/share/fonts/...: no such file or directory": the PDF writer was handing fpdf a font path, and fpdf rewrote the absolute path into a working-directory-relative one. Font data is now passed as bytes, with a fallback font compiled into the binary when the host has none. The runtime image therefore no longer installs DejaVuSans, which also removes the font layer from the builder stage. /monkeyd_crawl becomes public. The host allowlist and the single in-flight export were already the controls that bound its cost; they now carry that job alone, so both are load-bearing. --- Dockerfile | 10 ++------ README.md | 29 +++++++++++++---------- cmd/server/command_menu_test.go | 1 + internal/modules/monkeyd/handlers_test.go | 29 +++++++++++++++-------- internal/modules/monkeyd/monkeyd.go | 8 ++++--- third_party/monkeyd-crawler | 2 +- 6 files changed, 45 insertions(+), 34 deletions(-) diff --git a/Dockerfile b/Dockerfile index 86df086..0f7dc63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,6 @@ FROM golang:1.26.5-alpine AS builder WORKDIR /src -# The monkeyd module renders PDFs with an embedded TrueType font, and the -# runtime image below ships no fonts at all. DejaVuSans covers the Latin -# Extended Additional block that Vietnamese diacritics live in; it is installed -# here and copied into the final stage. -RUN apk add --no-cache font-dejavu - # The monkeyd-crawler submodule is resolved through a `replace` directive, so # its go.mod must be present before `go mod download` can read the build list. # Only the module files are copied here, keeping this layer cached across @@ -26,9 +20,9 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ ./cmd/server FROM gcr.io/distroless/static:nonroot +# No fonts are installed here: the monkeyd module's PDF renderer falls back to a +# font compiled into the binary when the host has none. COPY --from=builder /out/server /server -# pdfout.FindFont() probes system font paths; this is one of the paths it knows. -COPY --from=builder /usr/share/fonts/dejavu/DejaVuSans.ttf /usr/share/fonts/dejavu/DejaVuSans.ttf USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/server"] diff --git a/README.md b/README.md index 13c21d1..f08a4ba 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Atlas via long polling and an in-process cron scheduler. | `gold` | Gold paper trading (opt-in; VNAppMob SJC buy/sell VND/luong) | | `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) | | `stats` | `/stats` (top commands), `/stats users`, `/stats user `, `/stats cmd ` | -| `monkeyd` | `/monkeyd_crawl ` — export a monkeydd.com novel as a PDF (admin-only) | +| `monkeyd` | `/monkeyd_crawl ` — export a monkeydd.com novel as a PDF | Disable modules with the `MODULES` environment variable. @@ -138,24 +138,29 @@ crawling and rendering come from the [monkeyd-crawler](https://github.com/tiennm99/monkeyd-crawler) submodule; the module is the Telegram surface around it. -The command is admin-only, because one invocation makes hundreds of outbound -requests spread over several minutes. Only `monkeydd.com` URLs are accepted — -the extractor is written against that site's markup, and the allowlist also -keeps the bot from being used to fetch arbitrary URLs. A missing scheme is -filled in, so a pasted bare hostname works. +The command is public, so any member of a chat can run it. One invocation makes +hundreds of outbound requests spread over several minutes, so two things bound +the cost and are deliberate: only `monkeydd.com` URLs are accepted, and exactly +one export runs at a time across the whole bot. A missing scheme is filled in, +so a pasted bare hostname works. -Exports run one at a time. The bot replies immediately that the export started, -then sends the PDF when it is ready; a second request while one is in flight is -told which novel is currently running. Requests are spaced out by the crawler, -so the run is deliberately slow rather than aggressive. +The host allowlist is not only about the parser — the extractor is written +against that site's markup and would find nothing elsewhere — it also stops the +command being used to make the bot fetch arbitrary URLs. + +The bot replies immediately that the export started, then sends the PDF when it +is ready; a second request while one is in flight is told which novel is +currently running. Requests are spaced out by the crawler, so a run is +deliberately slow rather than aggressive. Raw pages are cached under the system temp directory, so re-exporting the same novel costs no requests. The cache is not pruned and a container restart clears it. Finished PDFs are deleted after upload. Telegram caps bot uploads at 50 MB; a larger book is reported instead of being sent. -The runtime image installs DejaVuSans, since the PDF embeds a font that covers -Vietnamese diacritics and the distroless base ships none. +The PDF embeds a font covering Vietnamese diacritics. The crawler prefers a +system font and falls back to one compiled into the binary, so the runtime +image needs no fonts installed. ## Layout diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 08dc52a..5c25fea 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -67,6 +67,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "gold_sell": "", "lol": "[date]", "loldle": "[champion]", + "monkeyd_crawl": "", "random": "", "stats": "[users | user | cmd ]", "stock_events": " [days]", diff --git a/internal/modules/monkeyd/handlers_test.go b/internal/modules/monkeyd/handlers_test.go index 1dd48d2..b512a5e 100644 --- a/internal/modules/monkeyd/handlers_test.go +++ b/internal/modules/monkeyd/handlers_test.go @@ -246,15 +246,24 @@ func TestCrawl_RepliesBusyWhileAnotherExportRuns(t *testing.T) { } } -// A Protected command must not respond at all to an unauthorized sender, or its -// existence leaks. -func TestCrawl_SilentForUnauthorizedSender(t *testing.T) { - rb, _ := install(t, 999) - rb.Bot.ProcessUpdate(context.Background(), - testutil.NewPrivateMessage(12345, "/monkeyd_crawl "+testNovelURL)) +// The command is public: a sender who is neither owner nor admin must still get +// a reply, including in a group. +func TestCrawl_AvailableToAnySender(t *testing.T) { + rb, r := install(t, 999) + dir := t.TempDir() + r.exporter = func(context.Context, export.Request) (*export.Result, error) { + return stubPDF(t, dir, "Example-Novel.pdf", 2048), nil + } - if calls := rb.Sent(); len(calls) != 0 { - t.Errorf("expected no reply to an unauthorized sender, got %+v", calls) + rb.Bot.ProcessUpdate(context.Background(), + testutil.NewGroupMessage(-100, 12345, "/monkeyd_crawl "+testNovelURL)) + + calls := rb.Sent() + if len(calls) == 0 { + t.Fatal("a non-admin sender got no reply from a public command") + } + if last := calls[len(calls)-1]; last.Method != "sendDocument" { + t.Errorf("last call = %q, want sendDocument", last.Method) } } @@ -267,8 +276,8 @@ func TestRegistration(t *testing.T) { if cmd.Name != commandName { t.Errorf("Name = %q, want %q", cmd.Name, commandName) } - if cmd.Visibility != modules.VisibilityProtected { - t.Errorf("Visibility = %v, want Protected", cmd.Visibility) + if cmd.Visibility != modules.VisibilityPublic { + t.Errorf("Visibility = %v, want Public", cmd.Visibility) } if cmd.Parameters != "" { t.Errorf("Parameters = %q, want %q", cmd.Parameters, "") diff --git a/internal/modules/monkeyd/monkeyd.go b/internal/modules/monkeyd/monkeyd.go index defac5b..6f3735e 100644 --- a/internal/modules/monkeyd/monkeyd.go +++ b/internal/modules/monkeyd/monkeyd.go @@ -39,9 +39,11 @@ func newModule(r *runner) modules.Module { Commands: []modules.Command{ { Name: commandName, - Visibility: modules.VisibilityProtected, - // One invocation makes hundreds of outbound requests over - // several minutes, so it stays off the public surface. + Visibility: modules.VisibilityPublic, + // Public despite being expensive: one invocation makes + // hundreds of outbound requests over several minutes. The + // host allowlist and the single in-flight export are what + // bound the cost, so both are load-bearing here. Description: "Export a " + AllowedHostsHint + " novel as a PDF", Parameters: "", Handler: r.handle, diff --git a/third_party/monkeyd-crawler b/third_party/monkeyd-crawler index c925f10..fb903e3 160000 --- a/third_party/monkeyd-crawler +++ b/third_party/monkeyd-crawler @@ -1 +1 @@ -Subproject commit c925f10ae0c4cf26f2ffbc4fcd893dedcdb81f7a +Subproject commit fb903e3262acbea4c941d5778b245574aa313290