@bongos/core 1.21.110 → 1.21.112
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +28 -18
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +39 -6
- package/docs/canonical-permissions.md +6 -3
- package/docs/module-api-changelog.md +4 -0
- package/modules/discord/bug-attachments.js +35 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/bug-attachment-audit.js +13 -7
- package/scripts/gds/bug-attachment-recover.js +198 -0
- package/src/module-api.js +1 -1
- package/tests/bug_attachment_audit.mjs +4 -3
- package/tests/bug_attachment_recover.mjs +128 -0
- package/tests/bug_attachments.mjs +30 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.21.
|
|
6
|
-
"core_contract": "1.21.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.21.112",
|
|
6
|
+
"core_contract": "1.21.112",
|
|
7
|
+
"source_commit": "20d2e0bfe68101dc84b13e8322b6c1dd35abcfe3",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-10-09T17:
|
|
9
|
+
"built_at": "2026-10-09T17:32:18.946Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 592,
|
|
13
13
|
"agent_docs_stubbed": 28,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 3036,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3657,
|
|
20
|
+
"tree_sha256": "8fb96168c1321372e872219e26f508d99b040b9ebce4ce6ca17e482c0216c0a0",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -612,7 +612,7 @@
|
|
|
612
612
|
{
|
|
613
613
|
"path": "docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md",
|
|
614
614
|
"mode": "0000644",
|
|
615
|
-
"sha256": "
|
|
615
|
+
"sha256": "9127ee3f769616ad16e7151cb7f5f5a461690303804ce0ee7ddf6410f101dbd0"
|
|
616
616
|
},
|
|
617
617
|
{
|
|
618
618
|
"path": "docs/adr/0044-mediterranean-palette-replacement.md",
|
|
@@ -2352,7 +2352,7 @@
|
|
|
2352
2352
|
{
|
|
2353
2353
|
"path": "docs/canonical-permissions.md",
|
|
2354
2354
|
"mode": "0000644",
|
|
2355
|
-
"sha256": "
|
|
2355
|
+
"sha256": "453a9449ea0dc1a5f50b1d786a4d5b14086423bfd02904c9a0df5c541347d144"
|
|
2356
2356
|
},
|
|
2357
2357
|
{
|
|
2358
2358
|
"path": "docs/copy-inventory.md",
|
|
@@ -2872,7 +2872,7 @@
|
|
|
2872
2872
|
{
|
|
2873
2873
|
"path": "docs/module-api-changelog.md",
|
|
2874
2874
|
"mode": "0000644",
|
|
2875
|
-
"sha256": "
|
|
2875
|
+
"sha256": "c7506ded99bfae21631034eaeda8c97b705dc16fe06bbf4e2419a257f578f755"
|
|
2876
2876
|
},
|
|
2877
2877
|
{
|
|
2878
2878
|
"path": "docs/modules-contract.md",
|
|
@@ -4682,7 +4682,7 @@
|
|
|
4682
4682
|
{
|
|
4683
4683
|
"path": "modules/discord/bug-attachments.js",
|
|
4684
4684
|
"mode": "0000644",
|
|
4685
|
-
"sha256": "
|
|
4685
|
+
"sha256": "2a5338b89b92b3fa4d8ec7659433f5d07d424d2c6902a6497916f5a3c27f8a54"
|
|
4686
4686
|
},
|
|
4687
4687
|
{
|
|
4688
4688
|
"path": "modules/discord/craft-broadcast.js",
|
|
@@ -10172,12 +10172,12 @@
|
|
|
10172
10172
|
{
|
|
10173
10173
|
"path": "package-lock.json",
|
|
10174
10174
|
"mode": "0000644",
|
|
10175
|
-
"sha256": "
|
|
10175
|
+
"sha256": "c0b56b64fd6deff145189d27a3aec0713132b1112d2545617719ee5d29f5e714"
|
|
10176
10176
|
},
|
|
10177
10177
|
{
|
|
10178
10178
|
"path": "package.json",
|
|
10179
10179
|
"mode": "0000644",
|
|
10180
|
-
"sha256": "
|
|
10180
|
+
"sha256": "3093047c8006d903bd5348bba1b43d9cd99f8dfd9ed19bb1575c8c92035a16b0"
|
|
10181
10181
|
},
|
|
10182
10182
|
{
|
|
10183
10183
|
"path": "public-docs/index.html",
|
|
@@ -10197,7 +10197,7 @@
|
|
|
10197
10197
|
{
|
|
10198
10198
|
"path": "release-notes.json",
|
|
10199
10199
|
"mode": "0000644",
|
|
10200
|
-
"sha256": "
|
|
10200
|
+
"sha256": "15e75913ea8e573944116d52fe5e44135ca7be4bc7a338cb6ee7d36f4b2a44d3"
|
|
10201
10201
|
},
|
|
10202
10202
|
{
|
|
10203
10203
|
"path": "scripts/cron/style_guide_audit.sh",
|
|
@@ -10452,7 +10452,12 @@
|
|
|
10452
10452
|
{
|
|
10453
10453
|
"path": "scripts/gds/bug-attachment-audit.js",
|
|
10454
10454
|
"mode": "0000644",
|
|
10455
|
-
"sha256": "
|
|
10455
|
+
"sha256": "fb9e587c2fc2d3664d1d936efd7508b364080129df082384ea1747223bf72394"
|
|
10456
|
+
},
|
|
10457
|
+
{
|
|
10458
|
+
"path": "scripts/gds/bug-attachment-recover.js",
|
|
10459
|
+
"mode": "0000644",
|
|
10460
|
+
"sha256": "5127a98a6bdbe2b7ff519ae6d258bdcd13d3d95386843f5efa202d2d319ffa8d"
|
|
10456
10461
|
},
|
|
10457
10462
|
{
|
|
10458
10463
|
"path": "scripts/gds/bug-triage.js",
|
|
@@ -12477,7 +12482,7 @@
|
|
|
12477
12482
|
{
|
|
12478
12483
|
"path": "src/module-api.js",
|
|
12479
12484
|
"mode": "0000644",
|
|
12480
|
-
"sha256": "
|
|
12485
|
+
"sha256": "a59fd4faf5376d371522507f8d01e336f655e2871ca2b4409f59606cccdfa28f"
|
|
12481
12486
|
},
|
|
12482
12487
|
{
|
|
12483
12488
|
"path": "src/module-loader/catalog.js",
|
|
@@ -13142,12 +13147,17 @@
|
|
|
13142
13147
|
{
|
|
13143
13148
|
"path": "tests/bug_attachment_audit.mjs",
|
|
13144
13149
|
"mode": "0000644",
|
|
13145
|
-
"sha256": "
|
|
13150
|
+
"sha256": "2c973440cf7339e716b0250c00a9045552b328851ddda09dddbb3d4ea130f5ef"
|
|
13151
|
+
},
|
|
13152
|
+
{
|
|
13153
|
+
"path": "tests/bug_attachment_recover.mjs",
|
|
13154
|
+
"mode": "0000644",
|
|
13155
|
+
"sha256": "ac43b7080bd1ff9c8e98a0bdcf074a79c7a002bea935c31cfcf3dec2931020c7"
|
|
13146
13156
|
},
|
|
13147
13157
|
{
|
|
13148
13158
|
"path": "tests/bug_attachments.mjs",
|
|
13149
13159
|
"mode": "0000644",
|
|
13150
|
-
"sha256": "
|
|
13160
|
+
"sha256": "02ab2e1b0e106b11e76f1868270c0c00e63b8ddbbf7f450343b3eba28672f56f"
|
|
13151
13161
|
},
|
|
13152
13162
|
{
|
|
13153
13163
|
"path": "tests/bug_triage.mjs",
|
|
@@ -19,7 +19,7 @@ The SSH deploy failed (their box had no droplet key), but it didn't matter: the
|
|
|
19
19
|
|
|
20
20
|
[ADR 0016](0016-trust-boundary-server-enforced-permissions.md) put authority in `builders.rank` and enforced it **server-side per request** — but **only for `/api/gds/*` HTTP routes**. It explicitly does not cover two surfaces:
|
|
21
21
|
|
|
22
|
-
1. **GitHub `main` is writable by every collaborator.** The repo is a **free-plan private repo**, so GitHub branch protection is *not available* ("Upgrade to GitHub Pro…"). All collaborators (`example-owner`, `LarsCode`, `willExample`, `IAMMASTERQUA`) have full push to `main`. The grader / confirm / rank gates live inside `ship.js` and the server — a raw `git push origin main` touches neither.
|
|
22
|
+
1. **GitHub `main` is writable by every collaborator.** The repo is a **free-plan private repo**, so GitHub branch protection is *not available* ("Upgrade to GitHub Pro…"). All collaborators (`example-owner`, `LarsCode`, `willExample`, `IAMMASTERQUA`) have full push to `main`. The grader / confirm / rank gates live inside `ship.js` and the server — a raw `git push origin main` touches neither. *(As written at the time. Since 2026-08-08 `main` is protected — a pull request plus the `unit` check for every non-admin; see §"Branch-protection status" for the current probes.)*
|
|
23
23
|
2. **The legit pipeline itself distributes the dangerous credentials.** `ship.js` does the merge-to-`main` and `ssh …deploy.sh` **client-side, from the builder's machine** — so the design hands every shipping builder the exact credentials needed to bypass it.
|
|
24
24
|
|
|
25
25
|
A second, softer problem Lars raised: a sub-Metic builder had free rein to **read into and reason about the tiering/permission code** in the first place. Newcomers (`xenos`/`thetes`) should stay in their lane.
|
|
@@ -30,13 +30,13 @@ Two threads.
|
|
|
30
30
|
|
|
31
31
|
### A. Close the code-landing path (chosen: Option A now, Option B later)
|
|
32
32
|
|
|
33
|
-
The protection goal is "un-graded code cannot reach prod, and a raw push to `main` is caught." Three layers, defense-in-depth, where **layer 3 is the authoritative enforcement** (the only one that survives a raw push / `--no-verify`, given GitHub
|
|
33
|
+
The protection goal is "un-graded code cannot reach prod, and a raw push to `main` is caught." Three layers, defense-in-depth, where **layer 3 is the authoritative enforcement** (the only one that survives a raw push / `--no-verify`, given GitHub couldn't block the push on the free plan when this was written). **Since 2026-08-08 GitHub does block a non-admin's direct push** — see §"Branch-protection status"; layer 3 stays authoritative for what branch protection does not cover (the admin's direct push, a self-merged PR with zero reviews, and *who* authored a protected path — the `unit` check passes a sub-Metic author as readily as anyone):
|
|
34
34
|
|
|
35
35
|
| Layer | Mechanism | Catches | Authoritative? |
|
|
36
36
|
|---|---|---|---|
|
|
37
37
|
| 1 — pre-push hook | `.husky/pre-push` → `scripts/gds/hooks/pre-push.js`. Blocks a direct push to `main` (override `OTB_ALLOW_MAIN_PUSH=1`, used only by the gated `scripts/gds/push-main.js`), and blocks a sub-Metic push that touches a permission-sensitive path. | the cooperative / careless case before the push leaves the box | no — bypassable with `--no-verify` |
|
|
38
38
|
| 2 — grader pre-pass | `grader.permissionPathPrePass(changedFiles, builderRank)` hard-fails the grade if a sub-Metic diff touches a protected path (mirrors the route-rank pre-pass). | the honest GDS ship flow | no — skippable with `--skip-grade` / raw push |
|
|
39
|
-
| 3 — post-push main audit | `scripts/gds/main-audit.js`: reads the **real** git history + **real** builder ranks (roster API, or Postgres via `--db` on the droplet). A resolved sub-Metic author on a protected path is a hard `[BLOCKER]`. Runs nightly (`.claude/scheduled-tasks/main-audit/`) **and** as the deploy gate (`--verify-head --db` from `~/deploy.sh`, `infra/deploy-main-audit-gate.sh`). | a raw `git push origin main
|
|
39
|
+
| 3 — post-push main audit | `scripts/gds/main-audit.js`: reads the **real** git history + **real** builder ranks (roster API, or Postgres via `--db` on the droplet). A resolved sub-Metic author on a protected path is a hard `[BLOCKER]`. Runs nightly (`.claude/scheduled-tasks/main-audit/`) **and** as the deploy gate (`--verify-head --db` from `~/deploy.sh`, `infra/deploy-main-audit-gate.sh`). | a raw `git push origin main` (since 2026-08-08 only the admin's; a non-admin's is refused by GitHub), a self-merged PR, a `--no-verify`, a `--skip-grade` — anything | **yes** — independent of the builder's cooperation, and the only layer that judges the author's RANK; branch protection proves a PR + green `unit`, not who may touch a protected path |
|
|
40
40
|
|
|
41
41
|
"Option A" = the merge-to-`main` + deploy move **server-side** so builders never hold main-push/droplet-SSH at all. The protections above ship now; the *full* server-side move and the credential rotation are operator steps (blockers [#15](https://example.com/builders#/blocker/15), [#16](https://example.com/builders#/blocker/16)). Until then, the legit client-side push is allowed only through `scripts/gds/push-main.js`, and the deploy gate (layer 3) is what guarantees poison never reaches prod.
|
|
42
42
|
|
|
@@ -69,7 +69,40 @@ After shipping, the fix was adversarially red-teamed (a 4-dimension panel + per-
|
|
|
69
69
|
|
|
70
70
|
**Not yet done (finding `#7`, follow-up):** the permission-path pre-pass (layer 2) runs only client-side in `ship.js`; the server `/grade` and `/confirm` routes don't re-run it. Layer 3 is the server-side backstop today; re-running the pre-pass server-side (the server holds the live rank in `req.builder.rank`) would harden the cooperative path.
|
|
71
71
|
|
|
72
|
-
## Branch-protection status: re-verified 2026-
|
|
72
|
+
## Branch-protection status: re-verified 2026-09-25 (task [1004669](https://cloudbongos.com/builders#/task/1004669))
|
|
73
|
+
|
|
74
|
+
**Current. `main` IS protected — a pull request plus the `unit` check, for everyone but the admin.**
|
|
75
|
+
Probed 2026-09-25 with the owner's (admin) GitHub account while checking task
|
|
76
|
+
[1001433](https://cloudbongos.com/builders#/task/1001433); the protection itself landed 2026-08-08
|
|
77
|
+
(task [1002619](https://cloudbongos.com/builders#/task/1002619)):
|
|
78
|
+
|
|
79
|
+
| Probe | Result |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `GET /repos/:o/:r/branches/main` | `protected: true` |
|
|
82
|
+
| `GET /repos/:o/:r/branches/main/protection` | `required_status_checks.contexts: ["unit"]` (`strict: false`) · `required_pull_request_reviews` present, `required_approving_review_count: 0` · `enforce_admins: false` · `allow_force_pushes: false` · `allow_deletions: false` |
|
|
83
|
+
| `GET /repos/:o/:r/rulesets` | `[]` — classic branch protection, not a ruleset |
|
|
84
|
+
| Collaborators with write | four, plus the admin owner; all four are Metic or Archon on the public leaderboard |
|
|
85
|
+
|
|
86
|
+
What this means for the claims above:
|
|
87
|
+
|
|
88
|
+
- **No longer true:** "a raw `git push origin main` by any collaborator is accepted". A non-admin's
|
|
89
|
+
direct push is refused; their work reaches `main` only through a PR whose `unit` check is green.
|
|
90
|
+
Force-pushing and deleting `main` are refused for everyone.
|
|
91
|
+
- **Still true:** the **admin** can push directly (`enforce_admins: false`); a write collaborator can
|
|
92
|
+
merge **their own** PR with zero reviews (the review count is 0 — the deliberate checks-only posture
|
|
93
|
+
of ADR 0162); and a builder **below Metic** reaches `main` only through the server's own publish
|
|
94
|
+
path (ADR 0055), never with a credential of their own.
|
|
95
|
+
- **So layer 3 (`main-audit.js`) stays the authoritative rank check.** Branch protection proves a
|
|
96
|
+
change came through a PR with a green `unit` run; it says nothing about whether its author may touch
|
|
97
|
+
a protected path. Option B (blocker [17](https://cloudbongos.com/builders#/blocker/17)) is half-delivered:
|
|
98
|
+
direct push is closed for non-admins, but nothing restricts `main` to the owner plus a deploy bot.
|
|
99
|
+
|
|
100
|
+
Treat this as the freshness stamp: re-run the probes above before asserting the claim has changed,
|
|
101
|
+
and update this section — not the history below — when it does.
|
|
102
|
+
|
|
103
|
+
## Branch-protection status: 2026-07-24 (history — superseded by the section above)
|
|
104
|
+
|
|
105
|
+
_Originally headed "re-verified 2026-07-24 (task [1001840](https://cloudbongos.com/builders#/task/1001840))"; kept as written._
|
|
73
106
|
|
|
74
107
|
The 2026-07-03 authority sweep reported that `main` had since gained live branch protection (required
|
|
75
108
|
checks + code-owner review, direct push blocked), which would have demoted `main-audit.js` from
|
|
@@ -90,8 +123,8 @@ private repo with no protection available, and **layer 3 (`main-audit.js`) remai
|
|
|
90
123
|
enforcement**, not a redundant second net. Option B (blocker
|
|
91
124
|
[#17](https://cloudbongos.com/builders#/blocker/17)) is still open and still the durable closure.
|
|
92
125
|
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
This was the freshness stamp until the 2026-08-08 protection flipped it; the current stamp is the
|
|
127
|
+
2026-09-25 section above.
|
|
95
128
|
|
|
96
129
|
## G-2/G-17 resolution: Layer 1 delivered; the "path bug" refuted (2026-08-08, task 1002608)
|
|
97
130
|
|
|
@@ -290,12 +290,15 @@ Reading the code is not rank-gated today; what a builder can LAND is (next secti
|
|
|
290
290
|
ADR 0016's per-request rank check covers `/api/bongos/*` only. Two surfaces it does **not** cover —
|
|
291
291
|
`git push` to `main` and `ssh` to the droplet — were used in the Builder 25 incident to land an
|
|
292
292
|
un-graded tiering change straight on prod `main`. **Since 2026-08-08 (task 1002619), `main` HAS
|
|
293
|
-
live branch protection: the required `unit` status check
|
|
294
|
-
|
|
293
|
+
live branch protection: a pull request plus the required `unit` status check, so a non-admin's
|
|
294
|
+
direct push is refused and their work lands via PR.** Re-probed 2026-09-25 (task
|
|
295
|
+
[1004669](https://cloudbongos.com/builders#/task/1004669)): `enforce_admins` is off, so the **admin**
|
|
296
|
+
can still push directly; the PR needs **zero** approvals, so a write collaborator can merge their own;
|
|
297
|
+
force-push and deletion of `main` are refused for everyone. The protection is *checks-only* — a code-owner review requirement was briefly
|
|
295
298
|
enforced and then deliberately retired by the owner (ADR 0162, task 1002635): gates are
|
|
296
299
|
rank-consistent CI checks, never a named human's approval. (The 2026-07-24 state — no protection
|
|
297
300
|
at all, PR #19 merged with zero checks — is preserved in ADR 0043 §"Branch-protection status" as
|
|
298
|
-
history.) `main-audit.js` remains the authoritative *post-merge* layer — it catches what any
|
|
301
|
+
history, below the current 2026-09-25 probes.) `main-audit.js` remains the authoritative *post-merge* layer — it catches what any
|
|
299
302
|
pre-merge gate misses, independent of the builder's cooperation:
|
|
300
303
|
|
|
301
304
|
- A builder **below Metic** (`xenos`, `thetes`) may **not modify permission-sensitive paths** — the
|
|
@@ -3057,5 +3057,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3057
3057
|
landed since 1.21.108 with no explicit bump. run 37962089344. (task 1002620)
|
|
3058
3058
|
1.21.110 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3059
3059
|
landed since 1.21.109 with no explicit bump. run 37963285770. (task 1002620)
|
|
3060
|
+
1.21.111 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3061
|
+
landed since 1.21.110 with no explicit bump. run 37965024171. (task 1002620)
|
|
3062
|
+
1.21.112 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3063
|
+
landed since 1.21.111 with no explicit bump. run 37966893511. (task 1002620)
|
|
3060
3064
|
---------------------------------------------------------------------------
|
|
3061
3065
|
```
|
|
@@ -55,10 +55,17 @@ const NAME_RE = /^bug-[a-z0-9]{1,40}-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
|
|
|
55
55
|
// used to be __dirname/../../var, which is the repo root in a single checkout and
|
|
56
56
|
// `node_modules/@bongos/core/var` on an instance running a vendored core — where
|
|
57
57
|
// every core reinstall wipes it. That is the task 1002237 shape, and these files
|
|
58
|
-
// are the
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
58
|
+
// are the hardest thing here to get back. What expires in about a day is the URL
|
|
59
|
+
// SIGNATURE (`?ex=&is=&hm=`), not the attachment: Discord keeps the file for as
|
|
60
|
+
// long as its message exists, and `GET /channels/{channel}/messages/{message}`
|
|
61
|
+
// with the bot token returns a freshly-signed URL. The stored filename carries
|
|
62
|
+
// the message id (`bug-<messageId>-<rand>.<ext>`), so a lost file is still
|
|
63
|
+
// addressable — `scripts/gds/bug-attachment-recover.js` re-fetches it to its
|
|
64
|
+
// original name (task 1004667). It stops working the day the message is deleted
|
|
65
|
+
// or the channel history is gone, so our copy is still the copy to keep. All five
|
|
66
|
+
// attachments this instance ever stored went missing from disk (the 2026-07-05
|
|
67
|
+
// droplet move carried the database and not var/), which is what this default is
|
|
68
|
+
// meant to stop happening again.
|
|
62
69
|
//
|
|
63
70
|
// BUG_ATTACHMENTS_DIR still wins, and on a box where the instance root is itself
|
|
64
71
|
// impermanent that override is the answer — the standup recipe says so.
|
|
@@ -155,12 +162,19 @@ function sniffMatches(buf, ext) {
|
|
|
155
162
|
// success, or { ok:false, reason } when the image is refused (never throws on a
|
|
156
163
|
// refusal; throws only on an unexpected fs error the caller wraps). `messageId`
|
|
157
164
|
// just seeds the (still-random) filename so attachments group readably.
|
|
165
|
+
//
|
|
166
|
+
// `file` (task 1004667) writes under a GIVEN name instead of a fresh one — the
|
|
167
|
+
// recovery path, re-fetching a lost image back to the exact name a task
|
|
168
|
+
// description already links. The name must pass isSafeName, its extension must
|
|
169
|
+
// match the bytes actually fetched, and an existing file is never overwritten.
|
|
170
|
+
// Same download path, same guards: recovery does not grow a second one.
|
|
158
171
|
// How many manual redirect hops we'll re-validate + follow before giving up.
|
|
159
172
|
// Discord CDN attachments are direct (0 redirects) in practice; a small bound
|
|
160
173
|
// covers a CDN edge bounce without becoming an open redirect-follow loop.
|
|
161
174
|
const MAX_REDIRECT_HOPS = 3;
|
|
162
175
|
|
|
163
|
-
async function persistImageFromUrl(url, { messageId = 'x', fetchImpl = globalThis.fetch, dir = attachmentsDir() } = {}) {
|
|
176
|
+
async function persistImageFromUrl(url, { messageId = 'x', fetchImpl = globalThis.fetch, dir = attachmentsDir(), file: givenFile = null } = {}) {
|
|
177
|
+
if (givenFile != null && !isSafeName(givenFile)) return { ok: false, reason: 'bad_name' };
|
|
164
178
|
if (!isAllowedAttachmentHost(url)) return { ok: false, reason: 'bad_host' };
|
|
165
179
|
// L3 residual (red-team 2026-06-12): fetch with redirect:'manual' and re-run
|
|
166
180
|
// the host allowlist on every Location before following. With redirect:'follow'
|
|
@@ -202,10 +216,23 @@ async function persistImageFromUrl(url, { messageId = 'x', fetchImpl = globalThi
|
|
|
202
216
|
if (buf.length > MAX_BYTES) return { ok: false, reason: 'too_big', bytes: buf.length };
|
|
203
217
|
if (!sniffMatches(buf, ext)) return { ok: false, reason: 'sniff_mismatch' };
|
|
204
218
|
|
|
205
|
-
|
|
206
|
-
|
|
219
|
+
let file;
|
|
220
|
+
if (givenFile != null) {
|
|
221
|
+
if (path.extname(givenFile).slice(1) !== ext) return { ok: false, reason: 'ext_mismatch', type: rawType };
|
|
222
|
+
file = givenFile;
|
|
223
|
+
} else {
|
|
224
|
+
const idSlug = String(messageId).replace(/[^a-z0-9]/gi, '').slice(0, 40).toLowerCase() || 'x';
|
|
225
|
+
file = `bug-${idSlug}-${crypto.randomBytes(8).toString('hex')}.${ext}`;
|
|
226
|
+
}
|
|
207
227
|
await fs.mkdir(dir, { recursive: true });
|
|
208
|
-
|
|
228
|
+
try {
|
|
229
|
+
// 'wx' only matters for a given name (a random one cannot collide): a
|
|
230
|
+
// recovery never clobbers a file that is already there.
|
|
231
|
+
await fs.writeFile(path.join(dir, file), buf, { mode: 0o640, flag: givenFile != null ? 'wx' : 'w' });
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (e && e.code === 'EEXIST') return { ok: false, reason: 'exists' };
|
|
234
|
+
throw e;
|
|
235
|
+
}
|
|
209
236
|
return { ok: true, file, url: `/api/bongos/bug-attachments/${file}`, bytes: buf.length };
|
|
210
237
|
}
|
|
211
238
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.112",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.112",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.112",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9518,5 +9518,17 @@
|
|
|
9518
9518
|
"id": "1004678",
|
|
9519
9519
|
"text": "The page-tweak helper can now run on its own: given one page, it applies the artist's words without asking anything, and if anything goes wrong it hands the page back with a plain note. The server enforces the safety rules its"
|
|
9520
9520
|
}
|
|
9521
|
+
],
|
|
9522
|
+
"1.21.111": [
|
|
9523
|
+
{
|
|
9524
|
+
"id": "1004667",
|
|
9525
|
+
"text": "Lost bug screenshots can now be brought back. We used to tell people a missing screenshot from the Discord bugs channel was gone for good. That was wrong: only the link expires, and the picture stays in Discord as long as the"
|
|
9526
|
+
}
|
|
9527
|
+
],
|
|
9528
|
+
"1.21.112": [
|
|
9529
|
+
{
|
|
9530
|
+
"id": "1004669",
|
|
9531
|
+
"text": "The security record for the main branch now says what is actually true. It used to say anyone with access could push straight to the main code line. That stopped being true in August: now every change from a regular collaborat"
|
|
9532
|
+
}
|
|
9521
9533
|
]
|
|
9522
9534
|
}
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
// scripts/gds/bug-attachment-audit.js — do the images our task descriptions
|
|
3
3
|
// promise actually exist? (task 1003918)
|
|
4
4
|
//
|
|
5
|
-
// WHY THIS EXISTS. A re-hosted #bugs screenshot is the
|
|
6
|
-
// system
|
|
7
|
-
//
|
|
5
|
+
// WHY THIS EXISTS. A re-hosted #bugs screenshot is the hardest artefact in this
|
|
6
|
+
// system to get back: the Discord CDN URL it was copied from carries a signature
|
|
7
|
+
// that expires in about a day. The FILE does not expire — Discord keeps it while
|
|
8
|
+
// the message exists, and bug-attachment-recover.js re-fetches it by the message
|
|
9
|
+
// id the filename carries (task 1004667) — but once that message or the channel
|
|
10
|
+
// history is gone, our copy IS the image. They live on disk
|
|
8
11
|
// under var/bug-attachments — outside Postgres, outside git — and nothing
|
|
9
12
|
// noticed when all five this instance had ever stored disappeared in the
|
|
10
13
|
// 2026-07-05 droplet move. The loss surfaced on 2026-09-14, two months later,
|
|
@@ -124,7 +127,8 @@ async function main() {
|
|
|
124
127
|
if (argv.includes('--line')) {
|
|
125
128
|
if (r.missing.length) {
|
|
126
129
|
console.log(`Bug attachments: ${r.missing.length} of ${r.described.length} linked screenshot(s) are MISSING from ${dir} `
|
|
127
|
-
+ '— every task describing one renders a broken image.
|
|
130
|
+
+ '— every task describing one renders a broken image. '
|
|
131
|
+
+ 'Try node scripts/gds/bug-attachment-recover.js --missing (it re-fetches from the original Discord message while that message still exists).');
|
|
128
132
|
}
|
|
129
133
|
return;
|
|
130
134
|
}
|
|
@@ -132,12 +136,14 @@ async function main() {
|
|
|
132
136
|
console.log(` Store: ${dir}`);
|
|
133
137
|
for (const m of r.missing) console.log(` missing ${m.name} (task ${m.taskId})`);
|
|
134
138
|
if (r.missing.length) {
|
|
135
|
-
console.log('
|
|
136
|
-
console.log('
|
|
139
|
+
console.log(' The stored Discord URL has expired, but only its signature: the message usually still holds the file,');
|
|
140
|
+
console.log(' and each filename carries its message id. Re-fetch them to their original names with:');
|
|
141
|
+
console.log(' node scripts/gds/bug-attachment-recover.js --missing');
|
|
142
|
+
console.log(' If the message is gone, restore from a nightly attachments archive: /var/backups/gds/<db>-attachments-*.tar.gz');
|
|
137
143
|
process.exit(1);
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
146
|
|
|
141
147
|
if (require.main === module) main();
|
|
142
148
|
|
|
143
|
-
module.exports = { describedAttachments, auditRows, LINK_RE };
|
|
149
|
+
module.exports = { describedAttachments, auditRows, fetchRows, LINK_RE };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/bug-attachment-recover.js — re-fetch a lost #bugs screenshot from
|
|
3
|
+
// the Discord message it came from, back to its ORIGINAL filename (task 1004667).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. We used to say a lost re-hosted screenshot "cannot be
|
|
6
|
+
// re-fetched — the Discord CDN URL has expired". Only the URL's signature
|
|
7
|
+
// (`?ex=&is=&hm=`) expires. Discord keeps the attachment for as long as its
|
|
8
|
+
// message exists, and GET /channels/{channel}/messages/{message} with the bot
|
|
9
|
+
// token hands back a freshly-signed URL. Every stored filename already carries
|
|
10
|
+
// the message id — discord-bot.js passes msg.id into `bug-<id>-<rand>.<ext>`,
|
|
11
|
+
// and a numeric snowflake survives the slug filter unchanged — so every "lost"
|
|
12
|
+
// file is still addressable. That false claim is what steered task 1003980 to
|
|
13
|
+
// "accept the loss" without anyone trying this.
|
|
14
|
+
//
|
|
15
|
+
// The file is written under the name the task description ALREADY links. A
|
|
16
|
+
// fresh random name would store the bytes and leave the task's image broken.
|
|
17
|
+
// The download itself goes through persistImageFromUrl — same host allowlist,
|
|
18
|
+
// redirect re-validation, magic-byte sniff and size cap — with only the target
|
|
19
|
+
// name supplied; there is no second download path here.
|
|
20
|
+
//
|
|
21
|
+
// Usage (on the box that holds the store and the bot token):
|
|
22
|
+
// node scripts/gds/bug-attachment-recover.js --missing # every file the audit finds missing
|
|
23
|
+
// node scripts/gds/bug-attachment-recover.js <message-id>... # the missing files from those messages
|
|
24
|
+
// node scripts/gds/bug-attachment-recover.js <filename>... # exactly these names (no task lookup)
|
|
25
|
+
// flags: --channel <id> (default DISCORD_BUGS_CHANNEL_ID, then bugs_channel_id in discord-bot.json)
|
|
26
|
+
// --dry-run (say what would be fetched; write nothing)
|
|
27
|
+
//
|
|
28
|
+
// Matching. A message's k-th described file (in the order the task description
|
|
29
|
+
// links them, which is the order the bot stored them) is fetched from that
|
|
30
|
+
// message's k-th image attachment. Never overwrites a file that is present.
|
|
31
|
+
// Exit 0 when everything asked for is recovered (or nothing was missing), 1 when
|
|
32
|
+
// anything could not be, 2 when it could not even start (no token, no channel).
|
|
33
|
+
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
const fs = require('node:fs');
|
|
37
|
+
|
|
38
|
+
const DISCORD_API = 'https://discord.com/api/v10';
|
|
39
|
+
// A Discord snowflake: digits only. Checked before either id goes into a URL.
|
|
40
|
+
const SNOWFLAKE_RE = /^\d{15,25}$/;
|
|
41
|
+
const NAME_RE = /^bug-([a-z0-9]{1,40})-[0-9a-f]{16}\.(?:png|jpg|gif|webp)$/;
|
|
42
|
+
|
|
43
|
+
// PURE — the message id a stored filename carries, or null when the slug is not
|
|
44
|
+
// a snowflake (a name stored with the 'x' fallback, or a hand-made one).
|
|
45
|
+
function messageIdFromName(name) {
|
|
46
|
+
const m = NAME_RE.exec(String(name || ''));
|
|
47
|
+
return m && SNOWFLAKE_RE.test(m[1]) ? m[1] : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// PURE — group filenames by the message they came from, keeping order within a
|
|
51
|
+
// message (that order is what pairs a name with an attachment). Names with no
|
|
52
|
+
// recoverable message id come back in `unaddressable`.
|
|
53
|
+
function planRecovery(names) {
|
|
54
|
+
const byMessage = new Map();
|
|
55
|
+
const unaddressable = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
for (const name of names || []) {
|
|
58
|
+
if (seen.has(name)) continue;
|
|
59
|
+
seen.add(name);
|
|
60
|
+
const id = messageIdFromName(name);
|
|
61
|
+
if (!id) { unaddressable.push(name); continue; }
|
|
62
|
+
if (!byMessage.has(id)) byMessage.set(id, []);
|
|
63
|
+
byMessage.get(id).push(name);
|
|
64
|
+
}
|
|
65
|
+
return { byMessage, unaddressable };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// PURE — the image attachments of a Discord REST message, in order. The same
|
|
69
|
+
// test the bot applied when it first stored them (discord-bot.js
|
|
70
|
+
// extractImageUrls): an image content type, or a width (Discord sets one only
|
|
71
|
+
// on images). REST returns snake_case `content_type`.
|
|
72
|
+
function imageAttachmentUrls(message) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const a of (message && message.attachments) || []) {
|
|
75
|
+
const type = a.content_type || a.contentType || '';
|
|
76
|
+
if ((type.startsWith('image/') || a.width != null) && a.url) out.push(a.url);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fetch one message and re-host its images under the given names. Returns one
|
|
82
|
+
// { name, ok, reason? } per name; never throws for an expected refusal.
|
|
83
|
+
async function recoverMessage({ channelId, messageId, names, token, fetchImpl = globalThis.fetch, dir, persist }) {
|
|
84
|
+
if (!SNOWFLAKE_RE.test(String(channelId)) || !SNOWFLAKE_RE.test(String(messageId))) {
|
|
85
|
+
return names.map((name) => ({ name, ok: false, reason: 'bad_id' }));
|
|
86
|
+
}
|
|
87
|
+
let resp;
|
|
88
|
+
try {
|
|
89
|
+
resp = await fetchImpl(`${DISCORD_API}/channels/${channelId}/messages/${messageId}`, {
|
|
90
|
+
headers: { Authorization: `Bot ${token}` },
|
|
91
|
+
});
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return names.map((name) => ({ name, ok: false, reason: 'fetch_failed', error: e.message }));
|
|
94
|
+
}
|
|
95
|
+
if (!resp || !resp.ok) {
|
|
96
|
+
// 404 is the case that ends recovery: the message (or the channel) is gone.
|
|
97
|
+
const reason = resp && resp.status === 404 ? 'message_gone' : `discord_status_${resp && resp.status}`;
|
|
98
|
+
return names.map((name) => ({ name, ok: false, reason }));
|
|
99
|
+
}
|
|
100
|
+
const urls = imageAttachmentUrls(await resp.json());
|
|
101
|
+
const results = [];
|
|
102
|
+
for (let i = 0; i < names.length; i++) {
|
|
103
|
+
const name = names[i];
|
|
104
|
+
if (!urls[i]) { results.push({ name, ok: false, reason: 'no_attachment_at_index', index: i, images: urls.length }); continue; }
|
|
105
|
+
const r = await persist(urls[i], { file: name, fetchImpl, dir });
|
|
106
|
+
results.push(r.ok ? { name, ok: true, bytes: r.bytes } : { name, ok: false, reason: r.reason });
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function argValue(argv, flag) {
|
|
112
|
+
const i = argv.indexOf(flag);
|
|
113
|
+
return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function loadChannelId(argv) {
|
|
117
|
+
const flag = argValue(argv, '--channel');
|
|
118
|
+
if (flag) return flag;
|
|
119
|
+
if (process.env.DISCORD_BUGS_CHANNEL_ID) return process.env.DISCORD_BUGS_CHANNEL_ID;
|
|
120
|
+
try {
|
|
121
|
+
const raw = require('../../src/module-api').readConfigFileSync('discord-bot.json');
|
|
122
|
+
const cfg = raw ? JSON.parse(raw) : null;
|
|
123
|
+
if (cfg && cfg.bugs_channel_id) return String(cfg.bugs_channel_id);
|
|
124
|
+
} catch (_) { /* not configured */ }
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function main() {
|
|
129
|
+
const argv = process.argv.slice(2);
|
|
130
|
+
const dryRun = argv.includes('--dry-run');
|
|
131
|
+
const channelArg = argValue(argv, '--channel');
|
|
132
|
+
const positional = argv.filter((a) => !a.startsWith('--') && a !== channelArg);
|
|
133
|
+
const at = require('../../modules/discord/bug-attachments.js');
|
|
134
|
+
|
|
135
|
+
// Which names to recover. Explicit filenames need no task lookup; --missing
|
|
136
|
+
// and message ids need the audit's list of described-but-missing files.
|
|
137
|
+
let names = positional.filter((a) => NAME_RE.test(a));
|
|
138
|
+
const ids = positional.filter((a) => SNOWFLAKE_RE.test(a));
|
|
139
|
+
const junk = positional.filter((a) => !NAME_RE.test(a) && !SNOWFLAKE_RE.test(a));
|
|
140
|
+
if (junk.length) { console.error(`bug-attachment-recover: not a filename or message id: ${junk.join(', ')}`); process.exitCode = 2; return; }
|
|
141
|
+
if (argv.includes('--missing') || ids.length) {
|
|
142
|
+
const audit = require('./bug-attachment-audit.js');
|
|
143
|
+
const r = audit.auditRows(await audit.fetchRows(), (name) => {
|
|
144
|
+
const p = at.resolveSafe(name);
|
|
145
|
+
return Boolean(p) && fs.existsSync(p);
|
|
146
|
+
});
|
|
147
|
+
const missing = r.missing.map((m) => m.name);
|
|
148
|
+
if (argv.includes('--missing')) names.push(...missing);
|
|
149
|
+
for (const id of ids) {
|
|
150
|
+
const mine = missing.filter((n) => messageIdFromName(n) === id);
|
|
151
|
+
if (!mine.length) console.log(` message ${id}: no missing file names it`);
|
|
152
|
+
names.push(...mine);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!positional.length && !argv.includes('--missing')) {
|
|
156
|
+
console.error('usage: bug-attachment-recover.js --missing | <message-id>... | <filename>... [--channel <id>] [--dry-run]');
|
|
157
|
+
process.exitCode = 2;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { byMessage, unaddressable } = planRecovery(names);
|
|
162
|
+
for (const n of unaddressable) console.log(` skip ${n} (its name carries no Discord message id)`);
|
|
163
|
+
if (!byMessage.size) {
|
|
164
|
+
console.log(' Nothing to recover.');
|
|
165
|
+
if (unaddressable.length) process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const channelId = loadChannelId(argv);
|
|
170
|
+
if (!channelId) { console.error('bug-attachment-recover: no #bugs channel id — pass --channel or set DISCORD_BUGS_CHANNEL_ID'); process.exitCode = 2; return; }
|
|
171
|
+
if (dryRun) {
|
|
172
|
+
for (const [id, list] of byMessage) for (const n of list) console.log(` would fetch ${n} from message ${id} in channel ${channelId}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const token = require('../../modules/discord/auth.js').loadDiscordBotToken();
|
|
176
|
+
if (!token) { console.error('bug-attachment-recover: no Discord bot token on this box (DISCORD_BOT_TOKEN or discord-bot.json)'); process.exitCode = 2; return; }
|
|
177
|
+
|
|
178
|
+
let failed = unaddressable.length;
|
|
179
|
+
for (const [messageId, list] of byMessage) {
|
|
180
|
+
// Present files are left alone: a re-run after a partial recovery only
|
|
181
|
+
// fetches what is still missing.
|
|
182
|
+
const todo = list.filter((n) => { const p = at.resolveSafe(n); return !(p && fs.existsSync(p)); });
|
|
183
|
+
for (const n of list) if (!todo.includes(n)) console.log(` present ${n}`);
|
|
184
|
+
if (!todo.length) continue;
|
|
185
|
+
const results = await recoverMessage({ channelId, messageId, names: todo, token, persist: at.persistImageFromUrl });
|
|
186
|
+
for (const r of results) {
|
|
187
|
+
if (r.ok) console.log(` restored ${r.name} (${r.bytes} bytes)`);
|
|
188
|
+
else { failed++; console.log(` FAILED ${r.name} (${r.reason})`); }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (failed) process.exitCode = 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (require.main === module) {
|
|
195
|
+
main().catch((e) => { console.error(`bug-attachment-recover: ${e.message || e}`); process.exitCode = 2; });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { messageIdFromName, planRecovery, imageAttachmentUrls, recoverMessage, SNOWFLAKE_RE };
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.112'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// WHY THIS FILE EXISTS. Five re-hosted #bugs screenshots — every one this instance
|
|
4
4
|
// ever stored — disappeared in the 2026-07-05 droplet move, and nobody noticed for
|
|
5
|
-
// two months.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// two months. The Discord CDN URL they were copied from carries a signature that
|
|
6
|
+
// expires in about a day; the file itself survives while its message does, and
|
|
7
|
+
// bug-attachment-recover.js re-fetches it (task 1004667). The probe is what should
|
|
8
|
+
// have said they were missing in July.
|
|
8
9
|
//
|
|
9
10
|
// A probe like this fails by finding nothing, and a clean bill of health looks
|
|
10
11
|
// identical to working — which is exactly what happened while building it: the
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// tests/bug_attachment_recover.mjs — re-fetching a lost #bugs screenshot from
|
|
2
|
+
// its Discord message, back to its original filename (task 1004667).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS. The recovery is only worth anything if the file lands
|
|
5
|
+
// under the name the task description already links — a fresh random name stores
|
|
6
|
+
// the bytes and leaves the task's image broken — and if it goes through the same
|
|
7
|
+
// guarded download as the bot (host allowlist, sniff, size cap). Both are pinned
|
|
8
|
+
// here against the REAL persistImageFromUrl with a fake fetch: no network, no
|
|
9
|
+
// Discord, a throwaway store.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/bug_attachment_recover.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import fs from 'node:fs/promises';
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
const TMP = path.join(os.tmpdir(), `otb-bugrecover-${process.pid}`);
|
|
21
|
+
process.env.BUG_ATTACHMENTS_DIR = TMP;
|
|
22
|
+
const at = require('../modules/discord/bug-attachments.js');
|
|
23
|
+
const rec = require('../scripts/gds/bug-attachment-recover.js');
|
|
24
|
+
|
|
25
|
+
let passed = 0, failed = 0;
|
|
26
|
+
function t(name, fn) {
|
|
27
|
+
return Promise.resolve().then(fn)
|
|
28
|
+
.then(() => { passed++; console.log(` PASS ${name}`); })
|
|
29
|
+
.catch((e) => { failed++; console.log(` FAIL ${name}\n ${e.message}`); });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
|
33
|
+
const CHANNEL = '111111111111111111';
|
|
34
|
+
const MSG = '1234567890123456789';
|
|
35
|
+
const A = `bug-${MSG}-0011223344556677.png`;
|
|
36
|
+
const B = `bug-${MSG}-8899aabbccddeeff.png`;
|
|
37
|
+
|
|
38
|
+
// One fake fetch for both legs: the Discord REST call returns `message`; a CDN
|
|
39
|
+
// URL returns the PNG. Records every URL and header it was asked for.
|
|
40
|
+
function fakeDiscord(message, { status = 200 } = {}) {
|
|
41
|
+
const calls = [];
|
|
42
|
+
const fetchImpl = async (url, opts = {}) => {
|
|
43
|
+
calls.push({ url, opts });
|
|
44
|
+
if (url.startsWith('https://discord.com/api/')) {
|
|
45
|
+
return { ok: status === 200, status, json: async () => message };
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
ok: true, status: 200,
|
|
49
|
+
headers: { get: (k) => (k.toLowerCase() === 'content-type' ? 'image/png' : null) },
|
|
50
|
+
arrayBuffer: async () => PNG.buffer.slice(PNG.byteOffset, PNG.byteOffset + PNG.byteLength),
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
return { fetchImpl, calls };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const att = (n, extra = {}) => ({ url: `https://cdn.discordapp.com/attachments/${CHANNEL}/${n}/shot.png?ex=1&is=2&hm=3`, content_type: 'image/png', ...extra });
|
|
57
|
+
|
|
58
|
+
console.log('Bug-attachment recovery (task 1004667):');
|
|
59
|
+
|
|
60
|
+
await t('messageIdFromName: reads the snowflake the bot put in the name', () => {
|
|
61
|
+
assert.equal(rec.messageIdFromName(A), MSG);
|
|
62
|
+
assert.equal(rec.messageIdFromName('bug-x-0011223344556677.png'), null, 'the x fallback carries no id');
|
|
63
|
+
assert.equal(rec.messageIdFromName('bug-m42-0011223344556677.png'), null, 'a non-numeric slug is not a message id');
|
|
64
|
+
assert.equal(rec.messageIdFromName('../../etc/passwd'), null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await t('planRecovery: groups by message, keeps link order, dedupes, sets aside the unaddressable', () => {
|
|
68
|
+
const { byMessage, unaddressable } = rec.planRecovery([A, 'bug-x-0011223344556677.png', B, A]);
|
|
69
|
+
assert.deepEqual([...byMessage.entries()], [[MSG, [A, B]]]);
|
|
70
|
+
assert.deepEqual(unaddressable, ['bug-x-0011223344556677.png']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await t('imageAttachmentUrls: images only, in order — the same test the bot stored them by', () => {
|
|
74
|
+
const urls = rec.imageAttachmentUrls({ attachments: [att(1), { url: 'https://cdn.discordapp.com/x.txt', content_type: 'text/plain' }, att(2, { content_type: undefined, width: 10 })] });
|
|
75
|
+
assert.equal(urls.length, 2);
|
|
76
|
+
assert.ok(urls[0].includes('/1/') && urls[1].includes('/2/'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await t('recoverMessage: asks Discord for the message with the bot token, then writes each image under its ORIGINAL name', async () => {
|
|
80
|
+
const { fetchImpl, calls } = fakeDiscord({ attachments: [att(1), att(2)] });
|
|
81
|
+
const out = await rec.recoverMessage({ channelId: CHANNEL, messageId: MSG, names: [A, B], token: 'tok', fetchImpl, dir: TMP, persist: at.persistImageFromUrl });
|
|
82
|
+
assert.deepEqual(out.map((r) => [r.name, r.ok]), [[A, true], [B, true]]);
|
|
83
|
+
assert.equal(calls[0].url, `https://discord.com/api/v10/channels/${CHANNEL}/messages/${MSG}`);
|
|
84
|
+
assert.equal(calls[0].opts.headers.Authorization, 'Bot tok');
|
|
85
|
+
assert.ok(calls[1].url.includes('/1/') && calls[2].url.includes('/2/'), 'k-th name ← k-th image');
|
|
86
|
+
for (const n of [A, B]) assert.equal((await fs.readFile(path.join(TMP, n))).length, PNG.length);
|
|
87
|
+
const onDisk = (await fs.readdir(TMP)).sort();
|
|
88
|
+
assert.deepEqual(onDisk, [A, B].sort(), 'no extra, randomly-named copy was written');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await t('recoverMessage: a deleted message is reported as message_gone, nothing written', async () => {
|
|
92
|
+
await fs.rm(TMP, { recursive: true, force: true });
|
|
93
|
+
const { fetchImpl } = fakeDiscord(null, { status: 404 });
|
|
94
|
+
const out = await rec.recoverMessage({ channelId: CHANNEL, messageId: MSG, names: [A], token: 'tok', fetchImpl, dir: TMP, persist: at.persistImageFromUrl });
|
|
95
|
+
assert.deepEqual(out.map((r) => r.reason), ['message_gone']);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await t('recoverMessage: more names than images → the extra name says so instead of reusing an image', async () => {
|
|
99
|
+
const { fetchImpl } = fakeDiscord({ attachments: [att(1)] });
|
|
100
|
+
const out = await rec.recoverMessage({ channelId: CHANNEL, messageId: MSG, names: [A, B], token: 'tok', fetchImpl, dir: TMP, persist: at.persistImageFromUrl });
|
|
101
|
+
assert.deepEqual(out.map((r) => r.ok || r.reason), [true, 'no_attachment_at_index']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('recoverMessage: a non-snowflake id never reaches a URL', async () => {
|
|
105
|
+
const { fetchImpl, calls } = fakeDiscord({ attachments: [] });
|
|
106
|
+
const out = await rec.recoverMessage({ channelId: '../../users/@me', messageId: MSG, names: [A], token: 'tok', fetchImpl, dir: TMP, persist: at.persistImageFromUrl });
|
|
107
|
+
assert.deepEqual([out[0].reason, calls.length], ['bad_id', 0]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await t('recoverMessage: the attachment download keeps the CDN host allowlist', async () => {
|
|
111
|
+
await fs.rm(TMP, { recursive: true, force: true });
|
|
112
|
+
const { fetchImpl } = fakeDiscord({ attachments: [{ url: 'https://evil.example/shot.png', content_type: 'image/png' }] });
|
|
113
|
+
const out = await rec.recoverMessage({ channelId: CHANNEL, messageId: MSG, names: [A], token: 'tok', fetchImpl, dir: TMP, persist: at.persistImageFromUrl });
|
|
114
|
+
assert.equal(out[0].reason, 'bad_host');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await t('the audit no longer claims a lost file cannot be re-fetched, and points at this tool', async () => {
|
|
118
|
+
const src = await fs.readFile(new URL('../scripts/gds/bug-attachment-audit.js', import.meta.url), 'utf8');
|
|
119
|
+
assert.ok(!/cannot be re-fetched/i.test(src));
|
|
120
|
+
assert.match(src, /bug-attachment-recover\.js --missing/);
|
|
121
|
+
const mod = await fs.readFile(new URL('../modules/discord/bug-attachments.js', import.meta.url), 'utf8');
|
|
122
|
+
assert.match(mod, /What expires in about a day is the URL\s*\n?\s*\/\/\s*SIGNATURE/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await t('cleanup temp dir', async () => { await fs.rm(TMP, { recursive: true, force: true }); });
|
|
126
|
+
|
|
127
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
128
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -107,6 +107,36 @@ await t('persistImageFromUrl: valid PNG → written to disk, returns durable url
|
|
|
107
107
|
assert.equal(onDisk.length, PNG.length);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
// ---- task 1004667: a GIVEN filename (the recovery path) ---------------------
|
|
111
|
+
const GIVEN = 'bug-1234567890123456789-0011223344556677.png';
|
|
112
|
+
await t('persistImageFromUrl({file}): writes under EXACTLY the given name', async () => {
|
|
113
|
+
const r = await at.persistImageFromUrl('https://cdn.discordapp.com/a.png', { file: GIVEN, fetchImpl: fakeFetch(PNG), dir: TMP });
|
|
114
|
+
assert.equal(r.ok, true);
|
|
115
|
+
assert.equal(r.file, GIVEN);
|
|
116
|
+
assert.equal((await fs.readFile(path.join(TMP, GIVEN))).length, PNG.length);
|
|
117
|
+
});
|
|
118
|
+
await t('persistImageFromUrl({file}): never overwrites a file that is present', async () => {
|
|
119
|
+
await fs.writeFile(path.join(TMP, GIVEN), 'KEEP');
|
|
120
|
+
const r = await at.persistImageFromUrl('https://cdn.discordapp.com/a.png', { file: GIVEN, fetchImpl: fakeFetch(PNG), dir: TMP });
|
|
121
|
+
assert.deepEqual([r.ok, r.reason], [false, 'exists']);
|
|
122
|
+
assert.equal(await fs.readFile(path.join(TMP, GIVEN), 'utf8'), 'KEEP');
|
|
123
|
+
});
|
|
124
|
+
await t('persistImageFromUrl({file}): an unsafe name is refused before any fetch', async () => {
|
|
125
|
+
let fetched = false;
|
|
126
|
+
const r = await at.persistImageFromUrl('https://cdn.discordapp.com/a.png', { file: '../../evil.png', fetchImpl: async () => { fetched = true; }, dir: TMP });
|
|
127
|
+
assert.deepEqual([r.ok, r.reason, fetched], [false, 'bad_name', false]);
|
|
128
|
+
});
|
|
129
|
+
await t('persistImageFromUrl({file}): bytes of another type than the name says → ext_mismatch, nothing written', async () => {
|
|
130
|
+
const name = 'bug-1234567890123456789-8899aabbccddeeff.jpg';
|
|
131
|
+
const r = await at.persistImageFromUrl('https://cdn.discordapp.com/a.png', { file: name, fetchImpl: fakeFetch(PNG), dir: TMP });
|
|
132
|
+
assert.deepEqual([r.ok, r.reason], [false, 'ext_mismatch']);
|
|
133
|
+
await assert.rejects(fs.access(path.join(TMP, name)));
|
|
134
|
+
});
|
|
135
|
+
await t('persistImageFromUrl({file}): the host allowlist still applies', async () => {
|
|
136
|
+
const r = await at.persistImageFromUrl('https://evil.example/a.png', { file: 'bug-1-8899aabbccddeeff.png', fetchImpl: fakeFetch(PNG), dir: TMP });
|
|
137
|
+
assert.equal(r.reason, 'bad_host');
|
|
138
|
+
});
|
|
139
|
+
|
|
110
140
|
// ---- L3 (#999): redirect:'manual' + re-validate the Location host ----------
|
|
111
141
|
// A fetch that returns a 30x with the given Location on the FIRST call, then the
|
|
112
142
|
// real PNG body on subsequent calls. Records the URLs it was asked to fetch.
|