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.
This commit is contained in:
2026-07-29 23:46:33 +07:00
parent d62e8a72b3
commit 85e07c7113
6 changed files with 45 additions and 34 deletions
+2 -8
View File
@@ -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"]
+17 -12
View File
@@ -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 <username>`, `/stats cmd <command_name>` |
| `monkeyd` | `/monkeyd_crawl <url>` — export a monkeydd.com novel as a PDF (admin-only) |
| `monkeyd` | `/monkeyd_crawl <url>` — 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
+1
View File
@@ -67,6 +67,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"gold_sell": "<luong>",
"lol": "[date]",
"loldle": "[champion]",
"monkeyd_crawl": "<url>",
"random": "<option,...>",
"stats": "[users | user <username> | cmd <command_name>]",
"stock_events": "<ticker> [days]",
+19 -10
View File
@@ -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 != "<url>" {
t.Errorf("Parameters = %q, want %q", cmd.Parameters, "<url>")
+5 -3
View File
@@ -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: "<url>",
Handler: r.handle,