mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-09-21 05:21:43 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e670cdfd9f | ||
|
|
b7ecf56745 | ||
|
|
74cc2b3d09 | ||
|
|
08422032e5 | ||
|
|
e1fb053fe0 | ||
|
|
5cdc80dcf1 | ||
|
|
402c3177d6 | ||
|
|
10f41feecb | ||
|
|
d8079c412a | ||
|
|
d786f3f23b | ||
|
|
f6381e3178 | ||
|
|
d8e01f3398 | ||
|
|
b8672a44d0 | ||
|
|
e123a5cc44 | ||
|
|
73b2c33305 | ||
|
|
06a48a419b | ||
|
|
7bc57ec227 | ||
|
|
148d688d43 | ||
|
|
b28e38d233 | ||
|
|
662f5ac711 | ||
|
|
960da01ec6 | ||
|
|
a46e746298 | ||
|
|
26be615918 | ||
|
|
eda882dc90 | ||
|
|
80d41ac78f | ||
|
|
40894267d4 | ||
|
|
9a45fdf6d9 | ||
|
|
809d72660d | ||
|
|
6102fd474f | ||
|
|
ffe5c920c6 | ||
|
|
c533d034f9 | ||
|
|
03822e0763 | ||
|
|
3536089d52 | ||
|
|
a8818dcf0c | ||
|
|
d57ec0458f | ||
|
|
6763af731f | ||
|
|
d5ba4fd4fd | ||
|
|
def8edb029 | ||
|
|
68742d682d | ||
|
|
f8bc737214 | ||
|
|
238ec17155 | ||
|
|
a2b7e77cc4 | ||
|
|
2beac6d62f | ||
|
|
897cd3d95e | ||
|
|
34e88a8cf5 | ||
|
|
5e7807b056 | ||
|
|
7693ed6f02 | ||
|
|
75c67b2036 |
@@ -89,7 +89,14 @@
|
||||
"Bash(wait:*)",
|
||||
"WebSearch",
|
||||
"Bash(SKILL_CREATOR_PATH=\"C:\\\\Users\\\\A00963355\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\skill-creator\\\\d5c15b861cd2\\\\skills\\\\skill-creator\" && WORKSPACE=\"C:\\\\Users\\\\A00963355\\\\OneDrive - Amaris Zorggroep\\\\Documents\\\\VScodeProjects\\\\DragonsStash\\\\.claude\\\\skills\\\\tdlib-telegram-workspace\\\\iteration-1\" && python \"$SKILL_CREATOR_PATH/eval-viewer/generate_review.py\" \"$WORKSPACE\" --skill-name \"tdlib-telegram\" --benchmark \"$WORKSPACE/benchmark.json\" --static \"$WORKSPACE/review.html\" 2>&1)",
|
||||
"Bash(start:*)"
|
||||
"Bash(start:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(DATABASE_URL=\"postgresql://dragons:stash@localhost:5432/dragonsstash\" npx prisma migrate dev --name add-skipped-packages)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(DATABASE_URL=\"postgresql://dragons:stash@localhost:5432/dragonsstash?schema=public\" npx prisma migrate dev --name add_package_groups 2>&1)",
|
||||
"Bash(psql:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git merge:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,3 +55,4 @@ src/generated
|
||||
nul
|
||||
tmpclaude-*
|
||||
.worktrees/
|
||||
worktrees/
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Disposable restore documentation fix report
|
||||
|
||||
## Scope
|
||||
|
||||
Updated `scripts/backup/README.md` only for the documentation change. This
|
||||
report is the requested verification artifact.
|
||||
|
||||
## Change
|
||||
|
||||
The monthly recovery rehearsal now documents a unique disposable Compose
|
||||
project with project-labeled volumes, snapshot selection, staging restore,
|
||||
PostgreSQL import, restoration of uploads and both TDLib volumes, disposable
|
||||
service startup, health and log checks, retained-file validation, known STL
|
||||
checksum and metadata comparison, narrowly scoped cleanup, and an evidence
|
||||
template.
|
||||
|
||||
The guide explicitly warns operators not to use production project names or
|
||||
volumes, and states that this documentation update did not run the rehearsal.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused text check: passed. Confirmed the guide contains the unique project
|
||||
warning, snapshot selection, staging restore, PostgreSQL import, all three
|
||||
protected volume restores, health check, retained-file validation, checksum
|
||||
comparison, scoped cleanup, evidence template, and the statement that the
|
||||
rehearsal was not run.
|
||||
- `git diff --check`: passed.
|
||||
- No recovery, Docker, Restic, PostgreSQL, or health-check commands were run;
|
||||
this change documents the operator procedure only.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Backup Final Review Fix Report
|
||||
|
||||
## Implemented findings
|
||||
|
||||
- The backup container now validates that `RESTIC_REPOSITORY` resolves strictly
|
||||
below `/backup`, verifies that the Restic repository already has a readable
|
||||
configuration before a scheduled backup, and gives the explicit first-run
|
||||
initialization command when that preflight fails.
|
||||
- The backup manifest now records every successfully applied Prisma migration
|
||||
as a JSON object with its name and UTC completion timestamp. The `psql`
|
||||
query uses the existing `DATABASE_URL` connection configuration and stops on
|
||||
query errors.
|
||||
- Restore now requires `BACKUP_REPOSITORY`, validates that it resolves strictly
|
||||
below `/backup`, and validates the configured backup mount (including the
|
||||
existing writable probe) before `restore-live` can stop services or replace
|
||||
live data.
|
||||
- The backup runbook now documents explicit repository initialization, monthly
|
||||
full-read Restic checks, a disposable restore/checksum rehearsal, and
|
||||
post-restore Compose status/log checks.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash -n scripts/backup/container-entrypoint.sh`
|
||||
- `bash -n scripts/backup/restore.sh`
|
||||
- Focused `rg` assertions for repository validation/preflight, migration
|
||||
timestamp metadata, restore mount validation, initialization, maintenance,
|
||||
and post-restore runbook commands.
|
||||
- `npx prisma validate`
|
||||
- `git diff --check`
|
||||
|
||||
## Scope and concerns
|
||||
|
||||
- No Docker, NAS, systemd, Restic repository, or live restore was run, per the
|
||||
bounded review scope. The command-level behavior is therefore statically
|
||||
validated only.
|
||||
- Existing durable STL handling, the exact live-restore confirmation flag, and
|
||||
rollback/safety-artifact behavior were retained.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Backup Scope-Correction Documentation Report
|
||||
|
||||
**Date:** 2026-07-22
|
||||
|
||||
## Changed files
|
||||
|
||||
- `docs/superpowers/specs/2026-07-21-database-and-file-backups-design.md`
|
||||
- `docs/superpowers/plans/2026-07-21-database-and-file-backups.md`
|
||||
- `.superpowers/sdd/scope-correction-docs-report.md`
|
||||
|
||||
## Rationale
|
||||
|
||||
The backup design and implementation plan now define the protected data set as a PostgreSQL logical dump plus the `tdlib_state` and `tdlib_bot_state` session volumes. They continue to require a host-restricted Synology NFS repository, Restic encryption, 30 daily snapshots, service quiescing, guarded restore, and session persistence.
|
||||
|
||||
`manual_uploads` and `tmp_zips` are explicitly excluded. The documents no longer require local retention of completed STL binaries, worker cleanup changes, database lifecycle fields for retained uploads, restored local STL files, or file-path validation. They state that STL binaries remain in Telegram and that the restored database preserves the metadata and mappings required to locate and send them.
|
||||
|
||||
Future Telegram channel-forwarding behavior and archive/STL-content integrity validation are explicitly identified as out of scope and future work.
|
||||
|
||||
## Checks run
|
||||
|
||||
- `git diff --check`
|
||||
- Scope scan of both documents for `manual_uploads`, `tmp_zips`, local STL retention, file-path validation, channel forwarding, and integrity language.
|
||||
- Reviewed the final diff to confirm the removed implementation work is limited to the specified backup-scope correction.
|
||||
- `git status --short` to confirm the commit stages only the two requested documents and this required report.
|
||||
|
||||
## Concerns
|
||||
|
||||
- This change intentionally updates documentation only. It does not modify backup scripts, Docker Compose, database schema, worker cleanup, or Telegram behavior.
|
||||
- A future implementation should validate its actual backup manifests and Compose mounts against this corrected plan before deployment.
|
||||
|
||||
---
|
||||
|
||||
# Monthly Backup Verification Documentation Follow-up
|
||||
|
||||
**Date:** 2026-07-22
|
||||
|
||||
## Fix
|
||||
|
||||
Aligned the approved backup design and implementation plan on the missing recurring operational work. The deployment operator now owns a documented monthly manual runbook task to run full `restic check --read-data`, perform a disposable restore rehearsal, and record the date, snapshot ID, integrity-check result, restore/health result, and cleanup result.
|
||||
|
||||
The plan verifies the first full-read check and rehearsal during acceptance, then carries the same procedure into the monthly runbook without adding a second systemd timer, script, or other production implementation. The Synology wording now precisely identifies the dedicated shared folder's NFS export as restricted to the Docker host's fixed IP.
|
||||
|
||||
## Scope preserved
|
||||
|
||||
The recovery set remains the PostgreSQL logical dump plus `tdlib_state` and `tdlib_bot_state` only. `manual_uploads`, STL binaries, archive/STL-content integrity, and future channel-forwarding checks remain outside this work.
|
||||
|
||||
## Checks completed
|
||||
|
||||
- `git diff --check` completed with no whitespace errors.
|
||||
- Focused assertions passed: `restic check --read-data` (4 matches), `deployment operator` (3), `disposable restore rehearsal` (6), `manual_uploads` (8), and `archive/STL-content integrity` (4) across the two approved documents.
|
||||
- Final diff review confirmed that this follow-up changes documentation only; no backup scripts, Compose configuration, or other production implementation files were modified.
|
||||
|
||||
---
|
||||
|
||||
# Monthly Backup Verification Review-Finding Fix
|
||||
|
||||
**Date:** 2026-07-22
|
||||
|
||||
## Fix
|
||||
|
||||
Confirmed and kept the approved design and implementation plan aligned on the reviewer finding: monthly recovery verification is an operator-owned operational task, consisting of a full `restic check --read-data` and a disposable restore rehearsal.
|
||||
|
||||
## Scope
|
||||
|
||||
The approved docs continue to limit the protected recovery set to the PostgreSQL logical dump, `tdlib_state`, and `tdlib_bot_state`. They do not add `manual_uploads`, STL-binary restore/checks, archive-content integrity checks, future channel-forwarding checks, or any new production timer/script.
|
||||
|
||||
## Checks
|
||||
|
||||
- `git diff --check`
|
||||
- Focused scope assertions over the two approved docs for monthly full Restic check/rehearsal wording and exclusions.
|
||||
- Staged-file review before commit to confirm the commit contains documentation/report files only.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Scope correction implementation report
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the approved backup scope correction for Dragon's Stash. Backups and restores now cover only:
|
||||
|
||||
- PostgreSQL logical custom-format dump plus manifest/migration metadata.
|
||||
- `tdlib_state` worker Telegram session volume.
|
||||
- `tdlib_bot_state` bot Telegram session volume.
|
||||
|
||||
The implementation no longer treats `manual_uploads`, completed local STL binaries, or `tmp_zips` as protected backup data. Future channel forwarding and archive/STL-content integrity auditing remain out of scope.
|
||||
|
||||
## Changed files
|
||||
|
||||
- `docker-compose.yml`
|
||||
- Removed the backup service's read-only `manual_uploads:/data/uploads` mount.
|
||||
- Kept the normal operational app/worker `manual_uploads` mounts.
|
||||
- Kept both TDLib backup mounts.
|
||||
|
||||
- `scripts/backup/container-entrypoint.sh`
|
||||
- Removed `/data/uploads` as a required mounted directory.
|
||||
- Removed uploads from the manifest `volumePaths`.
|
||||
- Removed the `source:uploads` Restic tag.
|
||||
- Removed uploads from the Restic source list.
|
||||
- Kept database dump, manifest, worker TDLib, and bot TDLib sources/tags.
|
||||
|
||||
- `scripts/backup/restore.sh`
|
||||
- Removed restored uploads variables.
|
||||
- Removed uploads staging validation.
|
||||
- Removed manual uploads volume discovery, safety archive, replacement, and rollback.
|
||||
- Removed retained/manual upload file-path verification.
|
||||
- Removed temporary database verification that existed only for local upload file references.
|
||||
- Preserved guarded `restore-live` confirmation, backup mount/repository checks, service stop/start handling, safety PostgreSQL dump, TDLib volume safety archives, TDLib volume replacement/rollback, and `pg_restore --list`/`pg_restore --exit-on-error` validation.
|
||||
|
||||
- `prisma/schema.prisma`
|
||||
- Removed `ManualUploadFile.retainedAt`.
|
||||
|
||||
- `prisma/migrations/20260722100000_remove_retained_manual_files/migration.sql`
|
||||
- Added forward migration: `ALTER TABLE "manual_upload_files" DROP COLUMN IF EXISTS "retainedAt";`
|
||||
- Preserved the existing committed migration that added `retainedAt`.
|
||||
|
||||
- `src/app/api/uploads/route.ts`
|
||||
- Removed `retainedAt: new Date()` from manual upload file creation.
|
||||
|
||||
- `worker/src/manual-upload.ts`
|
||||
- Restored final best-effort cleanup of `/data/uploads/<uploadId>` using the older `path.join("/data/uploads", uploadId)` behavior.
|
||||
|
||||
- `scripts/backup/README.md`
|
||||
- Rewrote backup set and restore rehearsal docs around PostgreSQL plus both TDLib volumes only.
|
||||
- Removed local STL file, retainedAt, upload path, retained file reference, and restored checksum checks.
|
||||
- Clarified that STL binaries remain in Telegram and recovery preserves database mappings/Telegram IDs.
|
||||
- Kept monthly `restic check --read-data` and disposable restore rehearsal runbook.
|
||||
- Explicitly left future channel forwarding and archive/STL-content integrity auditing out of scope.
|
||||
|
||||
- `README.md`
|
||||
- Updated the production backup summary to name PostgreSQL logical dump plus Telegram session volumes as the protected set.
|
||||
- Clarified that `manual_uploads` and temporary ZIPs are excluded and STL binaries remain in Telegram.
|
||||
|
||||
## Verification
|
||||
|
||||
- `git diff --check`
|
||||
- Passed.
|
||||
|
||||
- `bash -n scripts/backup/container-entrypoint.sh scripts/backup/run-backup.sh scripts/backup/restore.sh`
|
||||
- Local `bash` failed because Windows only had the WSL shim and no installed WSL distribution.
|
||||
- Passed via Docker fallback:
|
||||
`docker run --rm --entrypoint bash -v E:\Projects\DragonsStash:/work:ro -w /work postgres:16-alpine -n scripts/backup/container-entrypoint.sh scripts/backup/run-backup.sh scripts/backup/restore.sh`
|
||||
|
||||
- `npx prisma validate`
|
||||
- Passed.
|
||||
|
||||
- `npm run build`
|
||||
- Passed.
|
||||
|
||||
- `cd worker && npm run build`
|
||||
- Passed.
|
||||
|
||||
- Focused backup/restore scope assertions
|
||||
- Backup shell paths assertion passed: no `manual_uploads`, `/data/uploads`, `retainedAt`, upload source tag, or upload-restore helper references in `scripts/backup/*.sh`.
|
||||
- Compose backup service assertion passed: no `manual_uploads`, `/data/uploads`, `retainedAt`, or `source:uploads` in the `backup` service block.
|
||||
- Active retainedAt assertion passed: no `retainedAt` in active Prisma schema, upload API, worker source, or backup shell scripts.
|
||||
- Active backup/restore upload-source assertion passed: no `manual_uploads`, `/data/uploads`, or `data/uploads` in backup/restore shell scripts.
|
||||
- Remaining expected matches are limited to normal operational app/worker upload mounts and paths, docs stating exclusions, and the historical add/drop migrations.
|
||||
|
||||
## Concerns
|
||||
|
||||
- None for implementation scope.
|
||||
- Environment note: local Bash is unavailable because WSL has no installed distribution; Bash syntax was verified inside Docker instead.
|
||||
|
||||
---
|
||||
|
||||
# Restore Path Scope Review-Finding Fix
|
||||
|
||||
**Date:** 2026-07-22
|
||||
|
||||
## Fix
|
||||
|
||||
Addressed the Important restore finding by replacing both unrestricted
|
||||
`restic restore` calls in `scripts/backup/restore.sh` with a shared filtered
|
||||
restore wrapper. The wrapper restores only the backup source paths actually
|
||||
written by `scripts/backup/container-entrypoint.sh`:
|
||||
|
||||
- `/staging/backup-*/database.dump`
|
||||
- `/staging/backup-*/manifest` and `/staging/backup-*/manifest/**`
|
||||
- `/data/tdlib-worker` and `/data/tdlib-worker/**`
|
||||
- `/data/tdlib-bot` and `/data/tdlib-bot/**`
|
||||
|
||||
Added an explicit restored-tree guard that refuses unexpected restored content:
|
||||
top-level restored directories other than `staging` and `data`, direct
|
||||
`data/*` entries other than `tdlib-worker` and `tdlib-bot`, and direct
|
||||
`staging/backup-*/*` entries other than `database.dump` and `manifest`. This
|
||||
rejects old/broad snapshots that would otherwise restore `data/uploads`,
|
||||
temporary ZIP or database volume trees, or other unexpected volume content.
|
||||
|
||||
Preserved the existing guarded live-restore confirmation, staging-directory
|
||||
validation, mount/repository checks, snapshot verification, custom
|
||||
PostgreSQL-dump validation, service stop/start lifecycle, health check, safety
|
||||
database dump, TDLib safety archives, and rollback of exactly the PostgreSQL
|
||||
database plus the two TDLib volumes.
|
||||
|
||||
Added `scripts/backup/restore-path-assertions.sh`, a focused shell assertion
|
||||
harness that stubs Docker/Restic and verifies both staging and live restore use
|
||||
the expected include filters and that unexpected restored data-volume content is
|
||||
rejected explicitly.
|
||||
|
||||
Addressed the Minor documentation gap in the root README backup section by
|
||||
stating that forwarding behavior and archive/STL-content integrity auditing are
|
||||
future work outside the backup scope.
|
||||
|
||||
## Verification
|
||||
|
||||
- Red check before implementation:
|
||||
|
||||
```text
|
||||
& 'C:\Program Files\Git\bin\bash.exe' -lc 'scripts/backup/restore-path-assertions.sh'
|
||||
ASSERTION FAILED: database dump include filter missing
|
||||
```
|
||||
|
||||
- Bash syntax check:
|
||||
|
||||
```text
|
||||
& 'C:\Program Files\Git\bin\bash.exe' -lc 'bash -n scripts/backup/container-entrypoint.sh scripts/backup/run-backup.sh scripts/backup/restore.sh scripts/backup/restore-path-assertions.sh'
|
||||
[passed with no output]
|
||||
```
|
||||
|
||||
- Whitespace check:
|
||||
|
||||
```text
|
||||
git diff --check
|
||||
warning: in the working copy of '.superpowers/sdd/scope-correction-implementation-report.md', LF will be replaced by CRLF the next time Git touches it
|
||||
warning: in the working copy of 'README.md', LF will be replaced by CRLF the next time Git touches it
|
||||
warning: in the working copy of 'scripts/backup/restore.sh', LF will be replaced by CRLF the next time Git touches it
|
||||
[exit 0]
|
||||
```
|
||||
|
||||
- Focused restore-path assertions:
|
||||
|
||||
```text
|
||||
& 'C:\Program Files\Git\bin\bash.exe' -lc 'scripts/backup/restore-path-assertions.sh'
|
||||
restore-path assertions passed
|
||||
```
|
||||
|
||||
## Concerns
|
||||
|
||||
- None for implementation scope.
|
||||
- Git Bash was available and used for shell syntax/assertion checks, so Docker
|
||||
fallback was not needed for the final syntax verification.
|
||||
|
||||
---
|
||||
|
||||
# Important Operational Findings Fix
|
||||
|
||||
**Date:** 2026-07-22
|
||||
|
||||
## Fix
|
||||
|
||||
Addressed the two Important operational findings from final review:
|
||||
|
||||
- `scripts/backup/run-backup.sh`
|
||||
- Backup wrapper restart failures now make an otherwise successful backup
|
||||
exit non-zero.
|
||||
- Existing non-zero backup failures remain preserved if service restart also
|
||||
fails.
|
||||
- Added `scripts/backup/run-backup-assertions.sh` to assert both exit-code
|
||||
cases with a fake Docker/Compose environment.
|
||||
|
||||
- `scripts/backup/restore.sh`
|
||||
- `restore-live` now captures the managed services that were running before
|
||||
live restore using `docker compose --profile full ps --status running`.
|
||||
- Live restore stops only those previously running managed services.
|
||||
- Successful live restore starts only those previously running services, so
|
||||
the profile-gated optional `bot` is not started if it was not running.
|
||||
- Failure handling leaves services stopped and still rolls back only the
|
||||
PostgreSQL database plus both TDLib volumes.
|
||||
- App health wait now runs only when `app` was previously running.
|
||||
- Extended `scripts/backup/restore-path-assertions.sh` to assert subset
|
||||
stop/start behavior and skipped health checks when `app` was not running.
|
||||
|
||||
Addressed the Minor staging-path documentation mismatch by aligning
|
||||
`.env.example` with the backup README example:
|
||||
`/var/lib/dragons-stash/backup-staging`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Red checks before implementation:
|
||||
- `docker run --rm -v "${PWD}:/work" -w /work ubuntu:24.04 bash scripts/backup/run-backup-assertions.sh`
|
||||
- Failed as expected: backup reported success when restart failed.
|
||||
- `docker run --rm -v "${PWD}:/work" -w /work ubuntu:24.04 bash scripts/backup/restore-path-assertions.sh`
|
||||
- Failed as expected: restore-live stopped the fixed `app worker bot`
|
||||
service set instead of the running subset.
|
||||
|
||||
- Focused assertions after implementation:
|
||||
- `docker run --rm -v "${PWD}:/work" -w /work ubuntu:24.04 bash scripts/backup/run-backup-assertions.sh`
|
||||
- Passed.
|
||||
- `docker run --rm -v "${PWD}:/work" -w /work ubuntu:24.04 bash scripts/backup/restore-path-assertions.sh`
|
||||
- Passed.
|
||||
|
||||
- `git diff --check`
|
||||
- Passed.
|
||||
|
||||
- Docker Bash syntax check:
|
||||
- `docker run --rm -v "${PWD}:/work" -w /work ubuntu:24.04 bash -n scripts/backup/run-backup.sh scripts/backup/restore.sh scripts/backup/container-entrypoint.sh scripts/backup/restore-path-assertions.sh scripts/backup/run-backup-assertions.sh`
|
||||
- Passed.
|
||||
|
||||
- `npx prisma validate`
|
||||
- Passed.
|
||||
|
||||
- `npm run build`
|
||||
- Passed.
|
||||
|
||||
- `npm run lint`
|
||||
- Failed on unrelated existing React lint issues in `src/` and mirrored
|
||||
`.worktrees/worker-improvements` files; no failures were in touched backup
|
||||
files.
|
||||
|
||||
## Concerns
|
||||
|
||||
- Local `bash` is unavailable because the Windows `bash` command resolves to a
|
||||
WSL shim with no installed distribution; shell checks used Docker fallback.
|
||||
- Full `npm run lint` remains blocked by pre-existing unrelated lint errors.
|
||||
@@ -140,6 +140,18 @@ docker compose --profile bot up -d
|
||||
> **Tip:** Create a bot token via [@BotFather](https://t.me/BotFather) on Telegram and set `BOT_TOKEN` in `.env`.
|
||||
> Get Telegram API credentials from [my.telegram.org/apps](https://my.telegram.org/apps).
|
||||
|
||||
### Production Backups
|
||||
|
||||
Docker volumes are not backups. Production backups protect a PostgreSQL
|
||||
logical dump plus the worker and bot Telegram session volumes in an encrypted
|
||||
Restic repository on a Synology NFS share. `manual_uploads` and temporary ZIP
|
||||
processing data are excluded; STL binaries remain in Telegram, while the
|
||||
database mappings and Telegram IDs are what recovery preserves for lookup and
|
||||
delivery. Forwarding behavior and archive/STL-content integrity auditing are
|
||||
future work outside this backup scope. See the [backup and recovery guide](scripts/backup/README.md)
|
||||
for Synology setup, secrets, systemd installation, monitoring, retention, and
|
||||
guarded restore procedures.
|
||||
|
||||
### Seeding the Database
|
||||
|
||||
To seed the database with sample data on first run:
|
||||
|
||||
+7
-1
@@ -19,7 +19,13 @@ pg_dump -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc -f "$DUMP_F
|
||||
# TDLib volumes are tarred live (best-effort, per design). A file changing
|
||||
# mid-read makes GNU tar exit 1 (warning) — that is expected here and must not
|
||||
# abort the backup. Only a genuine error (exit >= 2) is fatal.
|
||||
tar --warning=no-file-changed -czf "$TAR_FILE" -C /data tdlib-worker tdlib-bot \
|
||||
#
|
||||
# files/temp is TDLib's own disposable download cache (redundant with the
|
||||
# source/destination Telegram chats, pruned by the worker itself after each
|
||||
# ingestion run) — it has no business in a backup and its size is what made
|
||||
# this tar take 4+ hours once the cache grew back to tens of GB.
|
||||
tar --warning=no-file-changed --exclude='tdlib-worker/*/files/temp' \
|
||||
-czf "$TAR_FILE" -C /data tdlib-worker tdlib-bot \
|
||||
|| { rc=$?; [ "$rc" -le 1 ] || exit "$rc"; }
|
||||
|
||||
restic backup "$DUMP_FILE" "$TAR_FILE"
|
||||
|
||||
@@ -53,6 +53,21 @@ export async function closeBotClient(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure TDLib has resolved this user into a private chat before sending.
|
||||
* A bot's local TDLib database only knows about chats it has seen since its
|
||||
* last (re)authentication — after a state-directory reset it has no chat
|
||||
* history cached, so `sendMessage` fails with "Chat not found" even for
|
||||
* users who messaged the bot long ago. `createPrivateChat` forces TDLib to
|
||||
* resolve/fetch the chat first.
|
||||
*/
|
||||
async function ensurePrivateChat(c: tdl.Client, userId: number): Promise<void> {
|
||||
await withFloodWait(
|
||||
() => c.invoke({ _: "createPrivateChat", user_id: userId, force: false }),
|
||||
"createPrivateChat"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a document from a channel to a user's DM.
|
||||
*
|
||||
@@ -71,6 +86,8 @@ export async function copyMessageToUser(
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
const c = client;
|
||||
|
||||
await ensurePrivateChat(c, Number(toUserId));
|
||||
|
||||
log.info(
|
||||
{ fromChatId: fromChatId.toString(), messageId: messageId.toString(), toUserId: toUserId.toString() },
|
||||
"Sending file to user"
|
||||
@@ -233,6 +250,8 @@ export async function sendTextMessage(
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
const c = client;
|
||||
|
||||
await ensurePrivateChat(c, Number(chatId));
|
||||
|
||||
// Parse the text first
|
||||
const parsed = await withFloodWait(
|
||||
() =>
|
||||
@@ -269,6 +288,8 @@ export async function sendPhotoMessage(
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
const c = client;
|
||||
|
||||
await ensurePrivateChat(c, Number(chatId));
|
||||
|
||||
// Write the photo to a temp file
|
||||
const { writeFile, unlink } = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Dragon's Stash off-host backup
|
||||
After=network-online.target docker.service
|
||||
Requires=docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
EnvironmentFile=-/etc/dragons-stash/backup.env
|
||||
WorkingDirectory=/opt/stacks/DragonsStash
|
||||
ExecStart=/opt/stacks/DragonsStash/scripts/backup/run-backup.sh
|
||||
TimeoutStartSec=infinity
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Nightly Dragon's Stash off-host backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:00:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=15m
|
||||
Unit=dragons-stash-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,450 @@
|
||||
# Database and File Backups Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a nightly, encrypted disaster-recovery backup for PostgreSQL and Telegram session volumes, stored on a Synology NAS.
|
||||
|
||||
**Architecture:** A Linux-host systemd timer invokes a host orchestration script. The script verifies the mounted Synology NFS share, stops the app/worker/bot services for consistency, and runs a one-shot Docker Compose backup service. The backup service creates a PostgreSQL custom-format dump and stores it with the two TDLib session volumes in a Restic repository on the NAS. A guarded restore command reconstructs the database and sessions, and documentation describes setup and testing. STL binaries remain in Telegram; restored database metadata and mappings continue to identify the Telegram content used for lookup and delivery.
|
||||
|
||||
**Tech Stack:** Docker Compose, PostgreSQL 16 `pg_dump`/`pg_restore`, Restic repository encryption and retention, Synology NFS, Linux systemd service/timer, Bash.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- PostgreSQL data must be backed up as a logical custom-format dump; the raw `postgres_data` volume is not the primary backup.
|
||||
- The `tdlib_state` and `tdlib_bot_state` volumes are included in every successful snapshot.
|
||||
- The `manual_uploads` and `tmp_zips` volumes are excluded.
|
||||
- Completed STL binaries are not retained locally for backup; existing worker cleanup behavior remains unchanged. Telegram remains the binary store, while PostgreSQL retains the metadata and mappings needed to locate and send the files after restore.
|
||||
- The Docker host must stop `app`, `worker`, and `bot` while session volumes are captured; PostgreSQL remains running for `pg_dump`.
|
||||
- The backup repository is encrypted and stored on a Synology NFS share restricted to the Docker host.
|
||||
- Retention is 30 daily snapshots; pruning is allowed only after a verified successful backup.
|
||||
- A failed run must restart services and preserve the last known-good snapshot.
|
||||
- A guarded restore must require explicit confirmation before replacing live database or session-volume data.
|
||||
- No in-app backup UI is part of this implementation.
|
||||
- Future Telegram channel-forwarding behavior and archive/STL-content integrity validation are explicitly out of scope.
|
||||
- The repository has no automated test framework; verification uses Bash syntax checks, Docker Compose validation, logs, Restic checks, and a disposable restore rehearsal.
|
||||
|
||||
---
|
||||
|
||||
## File and Responsibility Map
|
||||
|
||||
Create or modify only these focused units:
|
||||
|
||||
- Create `backup/Dockerfile`: build the one-shot image containing PostgreSQL client tools, Restic, Bash, and the backup entrypoint.
|
||||
- Create `scripts/backup/container-entrypoint.sh`: run the backup inside Compose, including dump creation, manifest creation, Restic snapshot, verification, and retention.
|
||||
- Create `scripts/backup/run-backup.sh`: host-level lock, NFS mount validation, service stop/start, and invocation of the one-shot Compose service.
|
||||
- Create `scripts/backup/restore.sh`: guarded restore orchestration for a selected Restic snapshot.
|
||||
- Create `deploy/systemd/dragons-stash-backup.service`: systemd unit invoking the host backup script.
|
||||
- Create `deploy/systemd/dragons-stash-backup.timer`: nightly schedule.
|
||||
- Create `scripts/backup/README.md`: Synology setup, host mount, secrets, first backup, restore, and operational troubleshooting.
|
||||
- Modify `docker-compose.yml`: add the profile-gated one-shot `backup` service and its read-only session-volume mounts.
|
||||
- Modify `.env.example`: document backup mount, staging, repository, and secret-file configuration without committing secrets.
|
||||
- Modify `README.md`: add the production backup setup and restore entry points, linking to the detailed backup guide.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the backup service and configuration contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.yml`
|
||||
- Modify: `.env.example`
|
||||
- Create: `backup/Dockerfile`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `db`, `tdlib_state`, and `tdlib_bot_state` Compose resources.
|
||||
- Produces: a profile-gated Compose service named `backup` that mounts the two session volumes read-only, connects to the `backend` network, and exposes `/backup` and `/staging` to the container entrypoint.
|
||||
|
||||
- [ ] **Step 1: Add explicit backup environment variables to `.env.example`**
|
||||
|
||||
Add this block without real credentials:
|
||||
|
||||
```dotenv
|
||||
# Disaster recovery backups
|
||||
BACKUP_MOUNT_PATH="/mnt/dragonsstash-backups"
|
||||
BACKUP_STAGING_PATH="/var/lib/dragons-stash-backup/staging"
|
||||
BACKUP_REPOSITORY="/backup/restic"
|
||||
BACKUP_RESTIC_PASSWORD_FILE="/etc/dragons-stash/restic-password"
|
||||
BACKUP_RETENTION_DAYS=30
|
||||
BACKUP_APP_VERSION="unknown"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the profile-gated `backup` service to `docker-compose.yml`**
|
||||
|
||||
Add a service with these properties:
|
||||
|
||||
```yaml
|
||||
backup:
|
||||
profiles: ["backup"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backup/Dockerfile
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-dragons}:${POSTGRES_PASSWORD:-stash}@db:5432/${POSTGRES_DB:-dragonsstash}
|
||||
RESTIC_REPOSITORY: ${BACKUP_REPOSITORY:-/backup/restic}
|
||||
RESTIC_PASSWORD_FILE: /run/secrets/restic-password
|
||||
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-30}
|
||||
BACKUP_APP_VERSION: ${BACKUP_APP_VERSION:-unknown}
|
||||
user: "0:0"
|
||||
volumes:
|
||||
- tdlib_state:/data/tdlib-worker:ro
|
||||
- tdlib_bot_state:/data/tdlib-bot:ro
|
||||
- ${BACKUP_MOUNT_PATH:?Set BACKUP_MOUNT_PATH to the mounted Synology share}:/backup:rw
|
||||
- ${BACKUP_STAGING_PATH:?Set BACKUP_STAGING_PATH to a local staging directory}:/staging:rw
|
||||
- ${BACKUP_RESTIC_PASSWORD_FILE:?Set BACKUP_RESTIC_PASSWORD_FILE to a root-readable secret file}:/run/secrets/restic-password:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend
|
||||
```
|
||||
|
||||
Do not mount `manual_uploads` or `tmp_zips`. Ensure the new service does not have `restart: always` and is not started by the normal production `docker compose up -d` command unless the `backup` profile is explicitly requested.
|
||||
|
||||
- [ ] **Step 3: Create the backup image definition**
|
||||
|
||||
Create `backup/Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM postgres:16-alpine
|
||||
|
||||
RUN apk add --no-cache bash restic coreutils
|
||||
|
||||
COPY scripts/backup/container-entrypoint.sh /usr/local/bin/dragons-stash-backup
|
||||
RUN chmod 0755 /usr/local/bin/dragons-stash-backup
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/dragons-stash-backup"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate the Compose contract**
|
||||
|
||||
Run on a Linux host with the required variables available:
|
||||
|
||||
```bash
|
||||
docker compose --profile backup config --quiet
|
||||
```
|
||||
|
||||
Expected: exit code `0` and no Compose validation errors. If the required NAS/secret paths are absent, the command must fail with the explicit variable-name error rather than silently using a host path.
|
||||
|
||||
- [ ] **Step 5: Commit the service boundary**
|
||||
|
||||
```bash
|
||||
git add backup/Dockerfile docker-compose.yml .env.example
|
||||
git commit -m "feat: add backup compose service"
|
||||
```
|
||||
|
||||
### Task 2: Implement the one-shot backup container
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/backup/container-entrypoint.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DATABASE_URL`, `RESTIC_REPOSITORY`, `RESTIC_PASSWORD_FILE`, `BACKUP_RETENTION_DAYS`, `/data/tdlib-worker`, `/data/tdlib-bot`, `/backup`, and `/staging`.
|
||||
- Produces: exit `0` only after a verified Restic snapshot and successful retention pruning; non-zero on any failed dump, snapshot, verification, or prune step.
|
||||
|
||||
- [ ] **Step 1: Define strict shell behavior and required inputs**
|
||||
|
||||
The script must begin with:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
```
|
||||
|
||||
Validate that `DATABASE_URL`, `RESTIC_REPOSITORY`, `RESTIC_PASSWORD_FILE`, and `BACKUP_RETENTION_DAYS` are set, that the password file is readable, and that `/backup` and `/staging` are mounted directories.
|
||||
|
||||
- [ ] **Step 2: Create a per-run staging directory and cleanup trap**
|
||||
|
||||
Use a directory below `/staging` named with UTC timestamp and process ID. Register an `EXIT` trap that removes only that directory. Never remove `/staging` itself or any directory under `/backup`.
|
||||
|
||||
- [ ] **Step 3: Create the PostgreSQL dump**
|
||||
|
||||
Run `pg_dump` using the connection URL and custom format:
|
||||
|
||||
```bash
|
||||
pg_dump --format=custom --file="$RUN_DIR/database.dump" "$DATABASE_URL"
|
||||
```
|
||||
|
||||
After the command succeeds, require the dump to be a non-empty regular file. Generate a SHA-256 checksum for the dump in the manifest directory.
|
||||
|
||||
- [ ] **Step 4: Create the manifest**
|
||||
|
||||
Write a JSON manifest containing the UTC backup timestamp, repository path, retention value, dump filename, dump checksum, and the two TDLib volume paths captured. Obtain the application image/version from an explicit `BACKUP_APP_VERSION` environment value when supplied; otherwise record `unknown` rather than guessing from mutable container state.
|
||||
|
||||
- [ ] **Step 5: Create one Restic snapshot**
|
||||
|
||||
Run one `restic backup` command against the staged database dump, manifest, and the two mounted persistent session volumes. Use stable source labels so the snapshot can be recognized during restore. Do not mount or include `manual_uploads` or `tmp_zips`.
|
||||
|
||||
- [ ] **Step 6: Verify and apply retention**
|
||||
|
||||
After `restic backup` succeeds:
|
||||
|
||||
```bash
|
||||
restic snapshots --latest 1
|
||||
restic check
|
||||
restic forget --keep-daily "$BACKUP_RETENTION_DAYS" --prune
|
||||
```
|
||||
|
||||
If any command fails, exit non-zero and do not run `forget --prune`. The host wrapper will restart the stopped services. When invoked with an unrecognized first argument, the entrypoint must pass the remaining arguments to the `restic` binary so operators can inspect the repository through the Compose image without installing Restic on the host:
|
||||
|
||||
```bash
|
||||
case "${1:-backup}" in
|
||||
backup) run_backup ;;
|
||||
restore) run_restore "$@" ;;
|
||||
*) exec restic "$@" ;;
|
||||
esac
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build and run a container-only smoke test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker build -f backup/Dockerfile -t dragons-stash-backup:smoke .
|
||||
bash -n scripts/backup/container-entrypoint.sh
|
||||
```
|
||||
|
||||
Expected: image build succeeds and Bash reports no syntax errors. The full snapshot test waits until the host wrapper and a real PostgreSQL/session-volume environment exist.
|
||||
|
||||
- [ ] **Step 8: Commit the backup container**
|
||||
|
||||
```bash
|
||||
git add backup/Dockerfile scripts/backup/container-entrypoint.sh
|
||||
git commit -m "feat: implement encrypted database and session snapshots"
|
||||
```
|
||||
|
||||
### Task 3: Add the host orchestration script and nightly systemd timer
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/backup/run-backup.sh`
|
||||
- Create: `deploy/systemd/dragons-stash-backup.service`
|
||||
- Create: `deploy/systemd/dragons-stash-backup.timer`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `.env`/deployment environment, the mounted `BACKUP_MOUNT_PATH`, Docker Compose project, and the `backup` service from Task 1.
|
||||
- Produces: one host command that safely stops and restarts services and returns the backup container's exit status; systemd runs it nightly.
|
||||
|
||||
- [ ] **Step 1: Implement lock and mount validation**
|
||||
|
||||
The host script must use `flock` on `/run/lock/dragons-stash-backup.lock`, reject a concurrent run, and validate the NAS mount with both `mountpoint --q "$BACKUP_MOUNT_PATH"` and a writable probe file that is immediately removed. A local directory at the same path must not pass validation. The script reads `BACKUP_MOUNT_PATH`, `BACKUP_STAGING_PATH`, `BACKUP_RESTIC_PASSWORD_FILE`, and `BACKUP_RETENTION_DAYS` from the systemd environment file.
|
||||
|
||||
- [ ] **Step 2: Capture service state and define guaranteed restart**
|
||||
|
||||
Before stopping services, record which of `app`, `worker`, and `bot` are running with `docker compose ps --status running -q SERVICE`. Stop only the services that were running. Register an `EXIT` trap that starts exactly those services and preserves the backup command's original exit code.
|
||||
|
||||
- [ ] **Step 3: Invoke the profile-gated backup service**
|
||||
|
||||
After the services stop, run:
|
||||
|
||||
```bash
|
||||
docker compose --profile backup run --rm backup backup
|
||||
```
|
||||
|
||||
Pass through the container exit code. The wrapper must not call the Restic retention command itself; that responsibility stays inside the backup container.
|
||||
|
||||
- [ ] **Step 4: Add the systemd service**
|
||||
|
||||
Create a unit with `Type=oneshot`, `User=root`, `EnvironmentFile=-/etc/dragons-stash/backup.env`, `WorkingDirectory` set to the production Compose directory, `ExecStart` pointing to the absolute `run-backup.sh` path, and `TimeoutStartSec=infinity`. Configure `After=network-online.target docker.service` and `Requires=docker.service`. Do not put the Restic password or database password in the unit file.
|
||||
|
||||
- [ ] **Step 5: Add the nightly timer**
|
||||
|
||||
Create a timer using `OnCalendar=*-*-* 03:00:00`, `Persistent=true`, and `RandomizedDelaySec=15m`. Set `Unit=dragons-stash-backup.service` and `WantedBy=timers.target`.
|
||||
|
||||
- [ ] **Step 6: Validate shell and systemd files**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bash -n scripts/backup/run-backup.sh
|
||||
systemd-analyze verify deploy/systemd/dragons-stash-backup.service deploy/systemd/dragons-stash-backup.timer
|
||||
```
|
||||
|
||||
Expected: both commands exit `0`. Run `systemctl list-timers dragons-stash-backup.timer` after installation and confirm the next run is scheduled.
|
||||
|
||||
- [ ] **Step 7: Commit scheduling and orchestration**
|
||||
|
||||
```bash
|
||||
git add scripts/backup/run-backup.sh deploy/systemd/dragons-stash-backup.service deploy/systemd/dragons-stash-backup.timer
|
||||
git commit -m "feat: schedule nightly off-host backups"
|
||||
```
|
||||
|
||||
### Task 4: Implement guarded restore tooling
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/backup/restore.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: a Restic snapshot ID, the same repository/password configuration, the backup Compose service, and the live Compose project.
|
||||
- Produces: restored PostgreSQL data and Telegram session volumes only after explicit confirmation for live replacement; a non-destructive staging restore by default.
|
||||
|
||||
- [ ] **Step 1: Define restore modes and destructive guard**
|
||||
|
||||
Support these commands:
|
||||
|
||||
```bash
|
||||
./scripts/backup/restore.sh list
|
||||
./scripts/backup/restore.sh verify SNAPSHOT_ID
|
||||
./scripts/backup/restore.sh restore-to-staging SNAPSHOT_ID STAGING_DIR
|
||||
./scripts/backup/restore.sh restore-live SNAPSHOT_ID --confirm-replace-live-data
|
||||
```
|
||||
|
||||
Reject `restore-live` unless the exact confirmation flag is present. `list`, `verify`, and `restore-to-staging` must not stop services or modify live volumes.
|
||||
|
||||
- [ ] **Step 2: Implement snapshot verification and staging restore**
|
||||
|
||||
Use `restic snapshots`, `restic check`, and `restic restore SNAPSHOT_ID --target STAGING_DIR`. Verify that the restored staging tree contains a non-empty custom-format dump, a manifest, `tdlib-worker`, and `tdlib-bot` before reporting success. Do not add file-path checks, binary checksums, archive/STL-content validation, or channel-forwarding behavior.
|
||||
|
||||
- [ ] **Step 3: Implement live restore sequencing**
|
||||
|
||||
For `restore-live`:
|
||||
|
||||
1. Confirm the Compose project and target repository.
|
||||
2. Stop `app`, `worker`, and `bot`.
|
||||
3. Create a safety PostgreSQL dump of the current database into local staging.
|
||||
4. Restore the selected snapshot to a separate staging directory.
|
||||
5. Replace the two Docker session volumes only after the restored tree passes validation.
|
||||
6. Recreate the configured database from the restored custom-format dump using `pg_restore --no-owner`.
|
||||
7. Start services and run the health endpoint plus worker/bot startup and authentication checks.
|
||||
|
||||
If any step fails, leave the services stopped, print the exact staging path and failure, and do not delete the safety dump.
|
||||
|
||||
- [ ] **Step 4: Validate the restore command without touching live data**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bash -n scripts/backup/restore.sh
|
||||
./scripts/backup/restore.sh list
|
||||
```
|
||||
|
||||
Expected: syntax passes and `list` prints available snapshot IDs without stopping any service or modifying a volume.
|
||||
|
||||
- [ ] **Step 5: Commit guarded restore tooling**
|
||||
|
||||
```bash
|
||||
git add scripts/backup/restore.sh
|
||||
git commit -m "feat: add guarded database and session restore"
|
||||
```
|
||||
|
||||
### Task 5: Document Synology setup, operations, and recovery
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/backup/README.md`
|
||||
- Modify: `README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the exact environment variables, systemd units, and restore commands from Tasks 1-4.
|
||||
- Produces: operator-facing instructions that do not require reading implementation files.
|
||||
|
||||
- [ ] **Step 1: Document Synology configuration**
|
||||
|
||||
Document creating the `dragonsstash-backups` shared folder, enabling NFS, and configuring that shared folder's NFS export to allow only the Docker host's fixed IP. Document mounting it at `/mnt/dragonsstash-backups`. Include commands for checking the mount:
|
||||
|
||||
```bash
|
||||
mountpoint /mnt/dragonsstash-backups
|
||||
touch /mnt/dragonsstash-backups/.write-test
|
||||
rm /mnt/dragonsstash-backups/.write-test
|
||||
```
|
||||
|
||||
Do not document exposing NFS to the Internet.
|
||||
|
||||
- [ ] **Step 2: Document secret and staging setup**
|
||||
|
||||
Document creating the root-readable Restic password file at `/etc/dragons-stash/restic-password`, creating the local staging directory, setting ownership/permissions, and adding the backup variables to the production environment without committing secrets.
|
||||
|
||||
- [ ] **Step 3: Document installation and first-run commands**
|
||||
|
||||
Include:
|
||||
|
||||
```bash
|
||||
sudo install -m 0644 deploy/systemd/dragons-stash-backup.service /etc/systemd/system/
|
||||
sudo install -m 0644 deploy/systemd/dragons-stash-backup.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now dragons-stash-backup.timer
|
||||
sudo systemctl start dragons-stash-backup.service
|
||||
sudo journalctl -u dragons-stash-backup.service -n 100 --no-pager
|
||||
```
|
||||
|
||||
Explain that the first run captures PostgreSQL and TDLib session state. State clearly that STL binaries stay in Telegram, and that restored PostgreSQL metadata and mappings are what allow normal lookup and delivery after restore.
|
||||
|
||||
- [ ] **Step 4: Document monitoring, retention, restore, and the monthly recovery check**
|
||||
|
||||
Document how to inspect timer status, service failures, Restic snapshots, repository checks, and the four restore modes. Explicitly state that `restore-live` is destructive and requires the confirmation flag. Assign the deployment operator a recurring monthly runbook task: run `docker compose --profile backup run --rm backup check --read-data`, then perform the documented disposable restore rehearsal using a selected snapshot. Record the date, snapshot ID, full-check result, restore/health result, and cleanup result. This is an operator-owned manual procedure, not a second production timer or a change to the nightly backup service. Limit the rehearsal to the PostgreSQL logical dump, `tdlib_state`, and `tdlib_bot_state`; do not add `manual_uploads`, STL-binary, archive-content, or channel-forwarding checks. Explain that channel-forwarding behavior and archive/STL-content integrity validation are future work, not restore checks.
|
||||
|
||||
- [ ] **Step 5: Add a concise production-backup section to the root README**
|
||||
|
||||
Add a link from the deployment/operations section to `scripts/backup/README.md`, state that Docker volumes are not backups, and identify the PostgreSQL logical dump and Telegram session volumes as the protected data set. State that manual uploads and temporary ZIPs are excluded and STL binaries remain in Telegram.
|
||||
|
||||
- [ ] **Step 6: Commit documentation**
|
||||
|
||||
```bash
|
||||
git add scripts/backup/README.md README.md
|
||||
git commit -m "docs: document Synology backup and recovery"
|
||||
```
|
||||
|
||||
### Task 6: Verify backup, failure recovery, retention, and restore
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/backup/README.md` only if verification commands need correction.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the complete backup stack from Tasks 1-5.
|
||||
- Produces: evidence that the acceptance criteria are met, including a full `restic check --read-data`, a disposable restore rehearsal, and a failure-path result. After deployment, the same full-check and rehearsal are an operator-owned monthly runbook task documented in Task 5.
|
||||
|
||||
- [ ] **Step 1: Validate configuration and scripts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker compose --profile backup config --quiet
|
||||
bash -n scripts/backup/container-entrypoint.sh scripts/backup/run-backup.sh scripts/backup/restore.sh
|
||||
systemd-analyze verify deploy/systemd/dragons-stash-backup.service deploy/systemd/dragons-stash-backup.timer
|
||||
```
|
||||
|
||||
Expected: all commands exit `0`.
|
||||
|
||||
- [ ] **Step 2: Seed recognizable database metadata**
|
||||
|
||||
Using the existing app/database workflow, identify a record whose Telegram archive, message, package, and file metadata can be recognized after restore. Record the expected database identifiers before backup. Do not create or retain a local STL binary for this verification.
|
||||
|
||||
- [ ] **Step 3: Run a real backup and inspect the snapshot**
|
||||
|
||||
Run the systemd service manually, then inspect:
|
||||
|
||||
```bash
|
||||
sudo systemctl start dragons-stash-backup.service
|
||||
sudo journalctl -u dragons-stash-backup.service --since "10 minutes ago" --no-pager
|
||||
docker compose --profile backup run --rm backup snapshots
|
||||
docker compose --profile backup run --rm backup check
|
||||
```
|
||||
|
||||
Expected: the service succeeds, the snapshot exists, the repository check succeeds, and all services are running again.
|
||||
|
||||
- [ ] **Step 4: Test the failure path with the NAS unavailable**
|
||||
|
||||
Temporarily unmount the Synology share in a controlled maintenance session, run the systemd service, and confirm it fails before creating a new snapshot. Remount the share and confirm the previously successful snapshot remains listed. Verify that services are running after the failed attempt.
|
||||
|
||||
- [ ] **Step 5: Run the full-read integrity check and rehearse a disposable restore**
|
||||
|
||||
Run `docker compose --profile backup run --rm backup check --read-data` against the selected repository, then restore the selected snapshot to a disposable Compose project or isolated Docker volumes. Import the database dump, restore the two TDLib session trees, start the disposable app/worker/bot services, and call `/api/health`. Confirm the recognizable database metadata and Telegram mappings match the pre-backup record. Record the check and rehearsal evidence as the initial monthly-runbook baseline. Do not assert the presence, checksum, content, or forwarding behavior of STL binaries.
|
||||
|
||||
- [ ] **Step 6: Verify retention behavior**
|
||||
|
||||
Use a disposable repository or controlled test timestamps to create more than 30 daily snapshots, run the retention command after a successful backup, and confirm that the latest 30 daily snapshots remain. Confirm a failed backup does not invoke pruning.
|
||||
|
||||
- [ ] **Step 7: Record verification evidence**
|
||||
|
||||
Add the actual commands, dates, snapshot ID, restore result, and any environment-specific caveats to the operational notes. Do not commit passwords, session contents, database dumps, or NAS addresses that are intended to remain private.
|
||||
|
||||
- [ ] **Step 8: Commit any documentation corrections**
|
||||
|
||||
```bash
|
||||
git add scripts/backup/README.md
|
||||
git commit -m "test: document verified backup and restore procedure"
|
||||
```
|
||||
|
||||
## Plan Self-Review
|
||||
|
||||
- **Spec coverage:** PostgreSQL logical dump, both Telegram session volumes, Synology NFS, Restic encryption, 30-day retention, maintenance window, service restart on failure, guarded restore, and an explicitly deployment-operator-owned monthly `restic check --read-data` plus disposable restore rehearsal are covered by Tasks 1-6. The initial run is verified in Task 6 and the recurring runbook is documented in Task 5; neither adds a second production timer.
|
||||
- **Exclusions:** `manual_uploads` and `tmp_zips` are excluded; local STL retention, restored STL binaries, file-path/checksum validation, channel forwarding, and archive/STL-content integrity checks are not implementation requirements.
|
||||
- **Placeholder scan:** No `TBD`, `TODO`, or unspecified implementation task remains. Environment-dependent values are explicit configuration variables or operator-supplied paths.
|
||||
- **Type/interface consistency:** The Compose service name is consistently `backup`; the container command modes are `backup` and `restore`; the host wrapper owns service lifecycle; the restore script owns destructive confirmation; Restic owns snapshots and pruning.
|
||||
- **Scope check:** The plan contains one operational subsystem with separate backup, restore, scheduling, and documentation units that can each be reviewed and tested independently.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
# Database and File Backup Design
|
||||
|
||||
**Date:** 2026-07-21
|
||||
**Status:** Design approved for written-spec review
|
||||
**Scope:** Disaster recovery for the Docker Compose deployment
|
||||
|
||||
## Problem
|
||||
|
||||
Dragon's Stash currently persists PostgreSQL in a Docker named volume. The Telegram worker and bot also persist authentication and session state in the `tdlib_state` and `tdlib_bot_state` volumes. Docker volumes protect against container recreation, but they are not off-host backups. A host disk failure, accidental deletion, corruption, or ransomware event could destroy the database and require both Telegram clients to authenticate again.
|
||||
|
||||
STL binaries are intentionally not retained as a local recovery set. They remain in Telegram. The PostgreSQL database preserves the archive, message, package, and file metadata needed to locate and send those Telegram-hosted binaries after the application database is restored. Existing worker cleanup behavior is unchanged.
|
||||
|
||||
## Goals
|
||||
|
||||
- Protect PostgreSQL data against loss of the application host with a logical backup.
|
||||
- Preserve Telegram worker and bot session state so a host restore does not normally require re-authentication.
|
||||
- Store backups on a Synology NAS over an authenticated, host-restricted NFS share.
|
||||
- Create one recoverable snapshot containing related database and session state.
|
||||
- Retain 30 daily recovery points.
|
||||
- Provide a documented, repeatable, guarded restore process.
|
||||
- Detect failed or corrupt backups instead of silently pruning the last good copy.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Building an in-app backup-management UI.
|
||||
- Backing up `manual_uploads`, retaining completed STL binaries locally, or changing worker cleanup behavior.
|
||||
- Backing up temporary ZIP processing data in `tmp_zips`.
|
||||
- Copying the raw `postgres_data` volume as the primary database backup.
|
||||
- Implementing future Telegram channel-forwarding behavior.
|
||||
- Validating the content or binary integrity of Telegram archives or STL files. This is future work and is outside the backup/restore feature.
|
||||
- Providing protection against loss of the NAS itself. A later Synology Hyper Backup task can replicate this repository to another device or cloud destination.
|
||||
|
||||
## Selected approach
|
||||
|
||||
Use a Linux-host backup script, scheduled by a systemd timer, with Restic writing to an encrypted repository on a Synology NFS share.
|
||||
|
||||
This approach keeps backup and restore explicit, avoids tying recovery to PostgreSQL's internal data-directory layout, and captures the sensitive TDLib state alongside the database. Restic repository encryption protects database contents and Telegram session state if the NAS share is accessed directly.
|
||||
|
||||
## Storage layout
|
||||
|
||||
### Synology
|
||||
|
||||
Create a dedicated shared folder, for example `dragonsstash-backups`, with:
|
||||
|
||||
- The dedicated backup shared folder is exported through NFS only to the Docker host's fixed IP address.
|
||||
- No Internet exposure.
|
||||
- Sufficient capacity for the repository plus growth and safety margin.
|
||||
|
||||
The Linux host mounts the share at a stable path such as `/mnt/dragonsstash-backups` using systemd-aware network mount options so a NAS outage does not block normal boot indefinitely.
|
||||
|
||||
### Restic repository
|
||||
|
||||
The repository lives below the mounted share. The Restic password is stored separately from the repository in a root-readable host secret file and must also be recorded in the operator's offline password-management system. Losing both the NAS and the only copy of the Restic password makes encrypted backups unrecoverable.
|
||||
|
||||
Each successful Restic snapshot contains:
|
||||
|
||||
- A PostgreSQL custom-format dump generated for that run.
|
||||
- The contents of the `tdlib_state` Docker volume.
|
||||
- The contents of the `tdlib_bot_state` Docker volume.
|
||||
- A small manifest with the backup timestamp, application image/version, database migration state, and captured volume paths.
|
||||
|
||||
The `manual_uploads` and `tmp_zips` volumes are excluded. STL binaries continue to live in Telegram; the restored database supplies the metadata and mappings required for the worker and bot to locate and send them.
|
||||
|
||||
## Backup flow
|
||||
|
||||
The systemd timer invokes one backup command at the chosen nightly time. The command:
|
||||
|
||||
1. Acquires an exclusive lock and refuses to run if another backup is active.
|
||||
2. Verifies that the NFS mount is present, writable, and points to the expected backup directory.
|
||||
3. Stops the `app`, `worker`, and `bot` services while leaving PostgreSQL running.
|
||||
4. Creates a PostgreSQL custom-format dump from the running database.
|
||||
5. Creates a manifest for the backup.
|
||||
6. Runs one Restic backup over the dump and the read-only mounted TDLib session volumes.
|
||||
7. Verifies that Restic created the snapshot successfully.
|
||||
8. Applies the retention policy: keep the latest 30 daily snapshots, then prune unreferenced data.
|
||||
9. Restarts all stopped services, whether the backup succeeded or failed.
|
||||
|
||||
The service-stop window ensures that application writes and TDLib session updates do not occur while the corresponding data is captured. A failed run must never trigger retention pruning.
|
||||
|
||||
## Restore flow
|
||||
|
||||
The restore tooling and documentation will support this sequence:
|
||||
|
||||
1. Stop `app`, `worker`, and `bot` for a live restore.
|
||||
2. Select and inspect a Restic snapshot.
|
||||
3. Restore the PostgreSQL dump and TDLib session contents to a staging location.
|
||||
4. Preserve the current database and volumes or confirm that the operator intends to replace them.
|
||||
5. Restore `tdlib_state` and `tdlib_bot_state` into their Docker volumes with the expected ownership and paths.
|
||||
6. Restore the database from the custom-format dump into the configured PostgreSQL database.
|
||||
7. Verify the dump, session-volume layout, database connectivity, and worker/bot authentication startup state.
|
||||
8. Start the services and inspect logs for startup, migration, and worker/bot authentication errors.
|
||||
|
||||
After restore, existing database metadata and mappings allow normal Telegram-based STL lookup and delivery. Restoring STL binaries, forwarding Telegram content, and checking archive/STL binary integrity are outside this restore flow.
|
||||
|
||||
The restore process must be safe to rehearse against a disposable Compose project without modifying the live deployment.
|
||||
|
||||
## Failure handling and verification
|
||||
|
||||
- A missing or read-only NAS mount fails the backup before services are stopped where possible.
|
||||
- A lock prevents overlapping backups.
|
||||
- A cleanup trap or equivalent guarantees service restart after errors.
|
||||
- Backup failure produces a non-zero systemd result and a clear log entry.
|
||||
- Retention pruning runs only after a verified successful snapshot.
|
||||
- A snapshot listing and repository metadata check run after each backup.
|
||||
- The deployment operator completes and records a monthly operational check: `restic check --read-data` followed by a disposable restore rehearsal. This verifies only the PostgreSQL logical dump and TDLib session-state recovery set; archive/STL-content integrity and future forwarding checks remain out of scope.
|
||||
- The project documentation describes how to inspect the last successful snapshot and how to recover when the NAS is unavailable.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- The NFS share is limited to the Docker host and is not exposed to the Internet.
|
||||
- Restic encryption protects the repository at rest.
|
||||
- PostgreSQL credentials, NAS credentials/rules, and the Restic password are never committed to the repository.
|
||||
- Restore commands must avoid printing database passwords or the Restic password in logs.
|
||||
- Telegram session volumes are included because they are operationally valuable, but they must be treated as secrets.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A nightly systemd timer creates a Restic snapshot on the Synology share.
|
||||
- A snapshot includes a PostgreSQL logical dump and both Telegram session volumes, while excluding `manual_uploads` and `tmp_zips`.
|
||||
- At least 30 daily recovery points are retained.
|
||||
- Each month, the deployment operator runs and records a full `restic check --read-data` and a disposable restore rehearsal of the PostgreSQL dump plus both TDLib session volumes.
|
||||
- A simulated host-loss restore reconstructs the database and Telegram session state in a disposable Compose environment.
|
||||
- The restored database retains the Telegram metadata and mappings the worker and bot use to locate and send STL binaries that remain in Telegram.
|
||||
- A failed backup leaves services running and preserves the last known-good snapshot.
|
||||
- The restore procedure is documented well enough for an operator to execute without reading the implementation.
|
||||
@@ -0,0 +1,211 @@
|
||||
# Forward-priority ingestion — design
|
||||
|
||||
**Date:** 2026-07-30
|
||||
**Status:** Approved (design), pending spec review → implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
The worker ingests every archive the same way regardless of whether it needs to: download the
|
||||
full file from the source channel, then re-upload the full file to the destination (archive)
|
||||
channel. That download+reupload round-trip was originally necessary because some source channels
|
||||
have "restrict saving content" (protected content) enabled, which blocks Telegram-native
|
||||
forwarding — for those channels there is no alternative to moving the bytes through the worker.
|
||||
|
||||
But most source channels do NOT restrict forwarding. For those, the round-trip is pure waste:
|
||||
Telegram can copy the message from source chat to destination chat server-side, with no bytes
|
||||
ever passing through the worker. The worker still needs to end up with the same outcome it has
|
||||
today — a destination-channel copy, a dedup-safe identity, and a full inner-file listing — just
|
||||
without paying for a download and re-upload to get there.
|
||||
|
||||
Separately, `feat/ranged-archive-listing` (merged to master ahead of this feature) already built
|
||||
exactly the missing piece: reading a ZIP/RAR/7z archive's inner-file listing via small ranged
|
||||
reads against the file wherever it currently lives (source channel, destination channel — doesn't
|
||||
matter), with no full download. It was built for backfilling listings onto already-deduped
|
||||
placeholder packages. This feature generalizes that same capability to fresh ingestion, and pairs
|
||||
it with a new native-forward upload path.
|
||||
|
||||
## Goals
|
||||
|
||||
- For channels that allow forwarding: skip download and re-upload entirely for new archives. Use
|
||||
Telegram-native forwarding from source chat to destination chat, and the existing ranged-listing
|
||||
readers to index inner files, with no full download in the common case.
|
||||
- For channels that block forwarding (or when forwarding isn't yet known): keep today's
|
||||
download+reupload pipeline exactly as-is.
|
||||
- Every ingested package — regardless of path — ends up with the same outcome as today: a
|
||||
`Package` row with a valid dedup identity, `destMessageId`/`destMessageIds`, creator, tags, and a
|
||||
full inner-file listing (`PackageFile` rows). Indexing completeness must not regress.
|
||||
- If the cheap ranged listing fails for a specific archive (bad/unsupported header, CLI error,
|
||||
etc.) in an otherwise-forwarding-eligible channel, fall back to today's full download+reupload
|
||||
pipeline for that one archive — never forward with an empty or partial listing.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No ranged single-entry preview extraction. Forward-path packages still get a preview when a
|
||||
channel photo message matches (cheap, unrelated to archive bytes); when there's no matching
|
||||
photo, forward-path packages simply have no preview, same as any package where preview
|
||||
extraction fails today. In-archive preview extraction (unzip/unrar/7z against a local file) stays
|
||||
as a download-path-only feature. May be revisited as a follow-up if it turns out to matter.
|
||||
- No reprocessing of already-ingested packages. This only changes behavior for newly-scanned
|
||||
archives going forward.
|
||||
- No change to the bot's user-delivery leg (`bot/src/tdlib/client.ts` `copyMessageToUser`) — it
|
||||
already sends via `inputFileRemote` with no download, and is unaffected by this feature.
|
||||
- No change to `config.maxZipSizeMB` or the multipart byte-level split/repack logic. The existing
|
||||
size guard runs before either path is chosen, so nothing above the cap reaches the forward path's
|
||||
fallback-to-download step either. Splitting simply never engages on the forward path — a
|
||||
forwarded message is already within whatever size Telegram accepted when it was first uploaded.
|
||||
|
||||
## Approaches considered
|
||||
|
||||
**A — Branch inside the existing pipeline (chosen).** Add one fork point in
|
||||
`processOneArchiveSet`, immediately after the existing pre-download dedup checks: if the channel
|
||||
allows forwarding, attempt the ranged-listing + forward path; on any failure, fall through into
|
||||
today's download-based code for that one archive, unchanged. Smallest diff; reuses the existing
|
||||
dedup/retry/watermark machinery as-is; matches the file's existing forum-vs-non-forum branching
|
||||
style.
|
||||
|
||||
**B — Separate pipeline per channel.** Decide once per channel and route the whole channel through
|
||||
either a "forward module" or the existing "download module." Cleaner separation on paper, but
|
||||
duplicates the SkippedPackage/stall/watermark bookkeeping that currently lives once in
|
||||
`processArchiveSets`/`processOneArchiveSet` — higher regression risk in a large orchestration file
|
||||
with no tests at that level. Rejected.
|
||||
|
||||
**C — Strategy-object refactor.** Extract an `IngestStrategy` interface (`download` / `forward`)
|
||||
and slim `processOneArchiveSet` to delegate to it. The more "proper" abstraction, but it's a
|
||||
structural refactor of already-battle-tested code that doesn't need it for this feature to work.
|
||||
Rejected — can revisit later if a third strategy ever appears.
|
||||
|
||||
## Sequencing
|
||||
|
||||
`feat/ranged-archive-listing` merges to master first, as-is (it's complete and serves a different
|
||||
purpose already). This feature is built on a fresh branch off master afterward.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `TelegramChannel.allowsForwarding` (new column, new migration)
|
||||
|
||||
`Boolean?` — nullable, `null` means "not yet checked". Refreshed from TDLib's chat
|
||||
protected-content flag (exact field name to be confirmed against the pinned `tdl`/TDLib version
|
||||
via docs lookup during implementation — expected to be `chat.has_protected_content`) at the same
|
||||
point the worker already calls `getChat` per channel per cycle, mirroring the existing
|
||||
`isForum`/`setChannelForum` read-and-persist pattern precisely. `null` or `false` both route to the
|
||||
download path — a channel never uses the forward path on unverified permission.
|
||||
|
||||
### 2. Shared ranged-listing dispatcher
|
||||
|
||||
`readScannedListingRanged` (plus `RangedPart`, `tdlibRangeReader`, and the format-specific
|
||||
ZIP/RAR/7z readers) currently live inside `provenance-backfill.ts`. Promote the dispatcher (and
|
||||
whatever it depends on) into a shared module (e.g. `worker/src/archive/ranged/dispatch.ts`) so
|
||||
`worker.ts` can call the same no-download listing logic for fresh ingestion without a circular
|
||||
import. `provenance-backfill.ts` switches to importing from the new shared location; behavior
|
||||
unchanged for the existing backfill path.
|
||||
|
||||
### 3. `forwardArchiveToChannel` (new, `worker/src/upload/forward.ts`)
|
||||
|
||||
Mirrors `uploadToChannel`'s shape and return type (`{ messageId, messageIds }`). Uses TDLib
|
||||
`forwardMessages` to copy all parts of an archive set from the source chat to the destination chat
|
||||
in one batch call (message IDs in original order), wrapped in the same flood-wait/retry handling
|
||||
style as `uploadToChannel`. Followed by the same destination read-back verification style as
|
||||
today's post-upload check (`getMessage` on each new destination message ID, confirm a document is
|
||||
present).
|
||||
|
||||
### 4. Dedup identity for forward-path packages
|
||||
|
||||
`Package.contentHash` stays a required unique string, but forward-path packages can't hash real
|
||||
bytes. Derivation order:
|
||||
1. If the ranged listing's CRC32s are complete (ZIP/RAR today) — hash the sorted CRC32 list into a
|
||||
synthetic `fingerprint:<hash>` value, reusing `archive/fingerprint.ts`'s existing
|
||||
`crcFingerprint`.
|
||||
2. Otherwise (7z, or any incomplete-CRC case) — synthesize `forward:<remoteUniqueId>`, following
|
||||
the existing `rebuild:`-prefixed placeholder-hash precedent in `rebuild.ts`.
|
||||
|
||||
Additionally, extend repost detection: before committing to the forward path, compare the new
|
||||
listing's CRC fingerprint (via the existing `compareFingerprints`/`fingerprintsMatch` logic already
|
||||
used in `provenance-backfill.ts`'s ambiguous-candidate disambiguation) against recent Packages
|
||||
sharing the same file name + size. A fingerprint match is treated as a duplicate and skipped, same
|
||||
as today's `findRepostedPackage` handling — this is what lets a forwarded copy and a previously
|
||||
fully-downloaded copy of the same archive still dedupe against each other, despite never sharing a
|
||||
byte-hash-derived `contentHash`.
|
||||
|
||||
### 5. Fork point in `processOneArchiveSet`
|
||||
|
||||
All existing pre-download checks run first, completely unchanged, in the same order:
|
||||
`remote.unique_id` match → `packageExistsBySourceMessage` → `findRepostedPackage` (name+size) →
|
||||
cross-channel provenance backfill → size guard (`maxZipSizeMB`).
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
if channel.allowsForwarding === true:
|
||||
entries = readScannedListingRanged(archiveType, client, scannedParts)
|
||||
if entries is not null:
|
||||
contentHash = deriveForwardContentHash(entries, remoteUniqueId)
|
||||
if fingerprintRepostCheck(entries, fileName, fileSize) finds a match:
|
||||
→ treat as duplicate, skip (same bookkeeping as today's dup path)
|
||||
destResult = forwardArchiveToChannel(client, sourceChatId, partMessageIds, destChatId)
|
||||
creator, tags ← derived from entries/filename/channel/topic, same as today
|
||||
preview ← channel-photo match only (no in-archive extraction)
|
||||
createPackageStub(...) + updatePackageWithMetadata(...), same as today
|
||||
counters.zipsForwarded++
|
||||
→ done
|
||||
else:
|
||||
→ fall through into the existing download/hash/split/upload flow below, unchanged
|
||||
(log the fallback for observability)
|
||||
else:
|
||||
→ existing download/hash/split/upload flow, completely unchanged
|
||||
```
|
||||
|
||||
### 6. Observability
|
||||
|
||||
New `zipsForwarded` counter alongside the existing `zipsFound`/`zipsDuplicate`/`zipsIngested`/
|
||||
`zipsBackfilled` counters, surfaced the same way (run activity, ingestion run summary). A WARN-level
|
||||
log line when a forwarding-eligible archive falls back to download (mirrors the existing
|
||||
`confidence: "ranged" | "full-download-fallback"` logging convention from the ranged-listing
|
||||
backfill work), so the fallback rate is visible without digging through debug logs.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
scan → pre-download dedup + size guard (unchanged)
|
||||
→ channel.allowsForwarding?
|
||||
true → ranged listing
|
||||
ok → fingerprint dedup check → forward → stub + entries + tags (no in-archive preview) → done
|
||||
null → [fall through] existing download pipeline
|
||||
false/unknown → existing download pipeline (unchanged)
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
- `forwardMessages` failure (permission revoked mid-run, rate limit, transient Telegram error) —
|
||||
same `SkippedPackage`/`SystemNotification` bookkeeping as today's upload failures. Extend
|
||||
`inferSkipReason` to recognize forward-specific error text the same way it already recognizes
|
||||
upload errors.
|
||||
- Fingerprint-repost check finds multiple ambiguous same-name/size candidates that can't be
|
||||
uniquely disambiguated — same `INTEGRITY_AUDIT` notification pattern already used in
|
||||
`provenance-backfill.ts`: don't guess, surface for manual triage.
|
||||
- `allowsForwarding` unknown (channel just linked, not yet scanned by the refresh point) — treated
|
||||
as `false`; the download path runs. No channel uses an unverified forwarding permission.
|
||||
- Ranged listing throwing instead of returning `null` — treated identically to returning `null`
|
||||
(fall through to download), consistent with how the existing ranged readers already treat
|
||||
internal errors (they catch and return `null` themselves).
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests (vitest, alongside the existing `archive/*.test.ts` and `archive/ranged/*.test.ts`
|
||||
files): the dedup-identity derivation function (fingerprint-hash vs remoteUniqueId-fallback
|
||||
branches), the extended fingerprint-based repost check, and `forwardArchiveToChannel`'s
|
||||
request-building logic against a mocked TDLib client — same style as the existing ranged-reader
|
||||
tests (pure logic, no live TDLib).
|
||||
- Live verification (manual — matches this repo's existing convention that the large
|
||||
`worker.ts`/`worker.py`-equivalent orchestration function has no automated test coverage and is
|
||||
verified live post-deploy): one forwarding-enabled test channel and one protected-content test
|
||||
channel. Confirm forward-path packages land with correct entries/tags/dedup identity and
|
||||
`destMessageIds`; confirm the protected channel still goes through the unchanged full pipeline;
|
||||
confirm a deliberately-unparseable archive in a forwarding-enabled channel correctly falls back
|
||||
to download+reupload and still ends up fully indexed.
|
||||
|
||||
## Rollout
|
||||
|
||||
Local build + deploy, following the same recipe as the ranged-archive-listing work: build
|
||||
`worker/Dockerfile` locally, recreate the `dragonsstash-worker` container from the local image (no
|
||||
`pull`, no GitHub push required). New DB migration for `TelegramChannel.allowsForwarding`. No
|
||||
changes required to the bot or app services.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "manual_upload_files" ADD COLUMN "retainedAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "manual_upload_files" DROP COLUMN IF EXISTS "retainedAt";
|
||||
@@ -0,0 +1,10 @@
|
||||
-- AlterTable: forward-priority ingestion support.
|
||||
-- allowsForwarding is nullable — null means "not yet checked", treated the
|
||||
-- same as false until confirmed true (safe default: download+reupload path).
|
||||
ALTER TABLE "telegram_channels"
|
||||
ADD COLUMN "allowsForwarding" BOOLEAN;
|
||||
|
||||
-- AlterTable: count of packages forwarded (no download) during a run.
|
||||
-- Additive, non-null with a default of 0 — no data change for existing rows.
|
||||
ALTER TABLE "ingestion_runs"
|
||||
ADD COLUMN "zipsForwarded" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -428,6 +428,11 @@ model TelegramChannel {
|
||||
isForum Boolean @default(false)
|
||||
isActive Boolean @default(false)
|
||||
category String? @db.VarChar(64)
|
||||
/// Whether this chat currently allows forwarding/saving (the inverse of
|
||||
/// TDLib's chat.has_protected_content). Null = not yet checked; treated the
|
||||
/// same as false everywhere in the worker (safe default: use the
|
||||
/// download+reupload path until this is confirmed true).
|
||||
allowsForwarding Boolean?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -570,6 +575,7 @@ model IngestionRun {
|
||||
zipsDuplicate Int @default(0)
|
||||
zipsIngested Int @default(0)
|
||||
zipsBackfilled Int @default(0)
|
||||
zipsForwarded Int @default(0)
|
||||
errorMessage String?
|
||||
|
||||
// Live activity tracking — written by worker in real-time
|
||||
|
||||
@@ -11,13 +11,21 @@ function extOf(name: string): string | null {
|
||||
return e === "" ? null : e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the End Of Central Directory record by scanning backward for its
|
||||
* signature. Returns -1 when the buffer holds no EOCD.
|
||||
*/
|
||||
export function findEocdOffset(tail: Buffer): number {
|
||||
for (let i = tail.length - 22; i >= 0; i--) {
|
||||
if (tail.readUInt32LE(i) === EOCD_SIG) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Parse a ZIP central directory from the tail of an archive. */
|
||||
export function parseZipCentralDirectoryFromTail(tail: Buffer, tailStart: number): FileEntry[] {
|
||||
// 1. Find EOCD by scanning backward for its signature.
|
||||
let eocd = -1;
|
||||
for (let i = tail.length - 22; i >= 0; i--) {
|
||||
if (tail.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; }
|
||||
}
|
||||
const eocd = findEocdOffset(tail);
|
||||
if (eocd < 0) throw new RangeError("EOCD not found in tail");
|
||||
|
||||
let cdSize = tail.readUInt32LE(eocd + 12);
|
||||
@@ -45,10 +53,22 @@ export function parseZipCentralDirectoryFromTail(tail: Buffer, tailStart: number
|
||||
}
|
||||
|
||||
// 3. Walk central-directory headers.
|
||||
return walkCentralDirectory(tail, cdLocal, cdSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a run of central-directory file headers and return the file entries
|
||||
* (directory entries are skipped).
|
||||
*
|
||||
* `buf` must contain the whole central directory starting at `start`; for a
|
||||
* spanned archive that means the caller has already stitched together the
|
||||
* volumes the directory straddles.
|
||||
*/
|
||||
export function walkCentralDirectory(tail: Buffer, start: number, cdSize: number): FileEntry[] {
|
||||
const entries: FileEntry[] = [];
|
||||
let p = cdLocal;
|
||||
const end = cdLocal + cdSize;
|
||||
while (p + 46 <= end && tail.readUInt32LE(p) === CD_SIG) {
|
||||
let p = start;
|
||||
const end = start + cdSize;
|
||||
while (p + 46 <= end && p + 46 <= tail.length && tail.readUInt32LE(p) === CD_SIG) {
|
||||
let crc = tail.readUInt32LE(p + 16) >>> 0;
|
||||
let comp = BigInt(tail.readUInt32LE(p + 20));
|
||||
let uncomp = BigInt(tail.readUInt32LE(p + 24));
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { detectArchive, isArchiveAttachment } from "./detect.js";
|
||||
|
||||
describe("detectArchive — numbered volumes (pack.EXT.001, pack.EXT.002, ...)", () => {
|
||||
it("recognizes a 7z multipart part as an archive attachment", () => {
|
||||
expect(isArchiveAttachment("Lost Adventures Vol2.7z.001")).toBe(true);
|
||||
});
|
||||
|
||||
it("extracts format, baseName, and partNumber", () => {
|
||||
const info = detectArchive("Lost Adventures Vol2.7z.001");
|
||||
expect(info).toEqual({
|
||||
baseName: "Lost Adventures Vol2.7z",
|
||||
partNumber: 1,
|
||||
format: "7Z",
|
||||
pattern: "ARCHIVE_NUMBERED",
|
||||
});
|
||||
});
|
||||
|
||||
it("groups multiple parts under the same baseName + format key regardless of part number", () => {
|
||||
const part1 = detectArchive("Lost Adventures Vol2.7z.001");
|
||||
const part2 = detectArchive("Lost Adventures Vol2.7z.010");
|
||||
expect(part1?.baseName).toBe(part2?.baseName);
|
||||
expect(part1?.format).toBe(part2?.format);
|
||||
expect(part2?.partNumber).toBe(10);
|
||||
});
|
||||
|
||||
it("is case-insensitive on the .7z extension", () => {
|
||||
expect(detectArchive("Archive.7Z.002")?.format).toBe("7Z");
|
||||
});
|
||||
|
||||
it("still recognizes a standalone single .7z file", () => {
|
||||
expect(detectArchive("Single Pack.7z")).toEqual({
|
||||
baseName: "Single Pack",
|
||||
partNumber: -1,
|
||||
format: "7Z",
|
||||
pattern: "SINGLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes numbered ZIP volumes", () => {
|
||||
expect(detectArchive("Big Pack.zip.001")).toEqual({
|
||||
baseName: "Big Pack.zip",
|
||||
partNumber: 1,
|
||||
format: "ZIP",
|
||||
pattern: "ARCHIVE_NUMBERED",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes numbered RAR volumes (previously dropped silently)", () => {
|
||||
expect(detectArchive("Big Pack.rar.001")).toEqual({
|
||||
baseName: "Big Pack.rar",
|
||||
partNumber: 1,
|
||||
format: "RAR",
|
||||
pattern: "ARCHIVE_NUMBERED",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives the format from the archive extension, not a hardcoded value", () => {
|
||||
expect(detectArchive("A.zip.004")?.format).toBe("ZIP");
|
||||
expect(detectArchive("A.RAR.004")?.format).toBe("RAR");
|
||||
expect(detectArchive("A.7z.004")?.format).toBe("7Z");
|
||||
});
|
||||
|
||||
it("accepts hand-renamed two-digit volumes for every format", () => {
|
||||
expect(detectArchive("Pack.zip.01")).toEqual({
|
||||
baseName: "Pack.zip",
|
||||
partNumber: 1,
|
||||
format: "ZIP",
|
||||
pattern: "ARCHIVE_NUMBERED",
|
||||
});
|
||||
expect(detectArchive("Pack.rar.02")?.partNumber).toBe(2);
|
||||
expect(detectArchive("Pack.7z.03")?.partNumber).toBe(3);
|
||||
});
|
||||
|
||||
it("still accepts four-or-more-digit volumes", () => {
|
||||
expect(detectArchive("Pack.7z.0001")).toEqual({
|
||||
baseName: "Pack.7z",
|
||||
partNumber: 1,
|
||||
format: "7Z",
|
||||
pattern: "ARCHIVE_NUMBERED",
|
||||
});
|
||||
expect(detectArchive("Pack.zip.10001")?.partNumber).toBe(10001);
|
||||
});
|
||||
|
||||
it("does not match a single-digit suffix (too ambiguous with real extensions)", () => {
|
||||
expect(detectArchive("Pack.zip.1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectArchive — pattern ordering safety", () => {
|
||||
it("does not let ZIP_LEGACY swallow a .7z.NNN name", () => {
|
||||
expect(detectArchive("Pack.7z.001")?.pattern).toBe("ARCHIVE_NUMBERED");
|
||||
expect(detectArchive("Pack.7z.001")?.format).toBe("7Z");
|
||||
});
|
||||
|
||||
it("does not let ARCHIVE_NUMBERED swallow legacy .zNN names", () => {
|
||||
expect(detectArchive("Pack.z01")).toEqual({
|
||||
baseName: "Pack",
|
||||
partNumber: 1,
|
||||
format: "ZIP",
|
||||
pattern: "ZIP_LEGACY",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let ARCHIVE_NUMBERED swallow legacy .rNN names", () => {
|
||||
expect(detectArchive("Pack.r00")).toEqual({
|
||||
baseName: "Pack",
|
||||
partNumber: 0,
|
||||
format: "RAR",
|
||||
pattern: "RAR_LEGACY",
|
||||
});
|
||||
expect(detectArchive("Pack.r01")?.pattern).toBe("RAR_LEGACY");
|
||||
});
|
||||
|
||||
it("keeps .partN.rar on the RAR_PART pattern", () => {
|
||||
expect(detectArchive("Pack.part2.rar")).toEqual({
|
||||
baseName: "Pack",
|
||||
partNumber: 2,
|
||||
format: "RAR",
|
||||
pattern: "RAR_PART",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectArchive — filename normalization", () => {
|
||||
it("recognizes a name with a trailing space and reports the trimmed baseName", () => {
|
||||
expect(detectArchive("Pack.zip ")).toEqual({
|
||||
baseName: "Pack",
|
||||
partNumber: -1,
|
||||
format: "ZIP",
|
||||
pattern: "SINGLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes a name with a leading space", () => {
|
||||
expect(detectArchive(" Pack.rar")).toEqual({
|
||||
baseName: "Pack",
|
||||
partNumber: -1,
|
||||
format: "RAR",
|
||||
pattern: "SINGLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes a multipart name with surrounding whitespace", () => {
|
||||
expect(detectArchive("\tPack.rar.002 \n")).toEqual({
|
||||
baseName: "Pack.rar",
|
||||
partNumber: 2,
|
||||
format: "RAR",
|
||||
pattern: "ARCHIVE_NUMBERED",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes a name with a trailing dot", () => {
|
||||
expect(detectArchive("Pack.zip.")).toEqual({
|
||||
baseName: "Pack",
|
||||
partNumber: -1,
|
||||
format: "ZIP",
|
||||
pattern: "SINGLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes a name with a trailing dot followed by a space", () => {
|
||||
expect(detectArchive("Pack.part3.rar. ")?.pattern).toBe("RAR_PART");
|
||||
});
|
||||
|
||||
it("still returns null for a whitespace-only or empty name", () => {
|
||||
expect(detectArchive(" ")).toBeNull();
|
||||
expect(detectArchive("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectArchive — self-extracting RAR first volume (.partN.exe)", () => {
|
||||
it("recognizes Pack.part1.exe as the first volume of a RAR_PART set", () => {
|
||||
expect(detectArchive("Pack.part1.exe")).toEqual({
|
||||
baseName: "Pack",
|
||||
partNumber: 1,
|
||||
format: "RAR",
|
||||
pattern: "RAR_PART",
|
||||
});
|
||||
});
|
||||
|
||||
it("groups the SFX first volume with its .rar continuation volumes", () => {
|
||||
const sfx = detectArchive("Pack.part1.exe");
|
||||
const cont = detectArchive("Pack.part2.rar");
|
||||
expect(sfx?.baseName).toBe(cont?.baseName);
|
||||
expect(sfx?.format).toBe(cont?.format);
|
||||
});
|
||||
|
||||
it("does not open the door to arbitrary .exe attachments", () => {
|
||||
expect(detectArchive("Installer.exe")).toBeNull();
|
||||
expect(detectArchive("Pack.exe")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectArchive — standalone documents", () => {
|
||||
it("keeps recognizing the pre-existing document extensions", () => {
|
||||
expect(detectArchive("Model.stl")).toEqual({
|
||||
baseName: "Model",
|
||||
partNumber: -1,
|
||||
format: "DOCUMENT",
|
||||
pattern: "SINGLE",
|
||||
});
|
||||
expect(detectArchive("Sheet.pdf")?.format).toBe("DOCUMENT");
|
||||
});
|
||||
|
||||
it("recognizes slicer-project formats", () => {
|
||||
for (const name of [
|
||||
"Bust.lys",
|
||||
"Bust.lyt",
|
||||
"Bust.lymesh",
|
||||
"Bust.chitubox",
|
||||
"Bust.ctp",
|
||||
"Bust.ctb",
|
||||
"Bust.cbddlp",
|
||||
"Bust.photon",
|
||||
"Bust.pwmx",
|
||||
"Bust.pwmo",
|
||||
"Bust.pws",
|
||||
"Bust.sl1",
|
||||
"Bust.goo",
|
||||
"Bust.phz",
|
||||
"Bust.pm3",
|
||||
"Bust.form",
|
||||
]) {
|
||||
expect(detectArchive(name), name).toEqual({
|
||||
baseName: "Bust",
|
||||
partNumber: -1,
|
||||
format: "DOCUMENT",
|
||||
pattern: "SINGLE",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes 3D-model and CAD formats", () => {
|
||||
for (const name of [
|
||||
"Bust.fbx",
|
||||
"Bust.ply",
|
||||
"Bust.glb",
|
||||
"Bust.gltf",
|
||||
"Bust.3ds",
|
||||
"Bust.max",
|
||||
"Bust.c4d",
|
||||
"Bust.ztl",
|
||||
"Bust.zpr",
|
||||
"Bust.mtl",
|
||||
"Bust.f3d",
|
||||
"Bust.scad",
|
||||
"Bust.igs",
|
||||
"Bust.iges",
|
||||
"Bust.sldprt",
|
||||
"Bust.skp",
|
||||
"Bust.wrl",
|
||||
]) {
|
||||
expect(detectArchive(name), name).toEqual({
|
||||
baseName: "Bust",
|
||||
partNumber: -1,
|
||||
format: "DOCUMENT",
|
||||
pattern: "SINGLE",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes the .blend1 autosave sibling of .blend", () => {
|
||||
expect(detectArchive("Scene.blend")?.format).toBe("DOCUMENT");
|
||||
expect(detectArchive("Scene.blend1")).toEqual({
|
||||
baseName: "Scene",
|
||||
partNumber: -1,
|
||||
format: "DOCUMENT",
|
||||
pattern: "SINGLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT recognize image attachments (they must not become packages)", () => {
|
||||
for (const name of ["Preview.jpg", "Preview.jpeg", "Preview.png", "Preview.webp", "Preview.gif"]) {
|
||||
expect(detectArchive(name), name).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for unrelated files", () => {
|
||||
expect(detectArchive("notes.txt")).toBeNull();
|
||||
expect(detectArchive("song.mp3")).toBeNull();
|
||||
expect(detectArchive("noextension")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,25 +4,29 @@ export interface MultipartInfo {
|
||||
baseName: string;
|
||||
partNumber: number;
|
||||
format: ArchiveFormat;
|
||||
pattern: "ZIP_NUMBERED" | "ZIP_LEGACY" | "RAR_PART" | "RAR_LEGACY" | "SINGLE";
|
||||
pattern: "ARCHIVE_NUMBERED" | "ZIP_LEGACY" | "RAR_PART" | "RAR_LEGACY" | "SINGLE";
|
||||
}
|
||||
|
||||
const patterns: {
|
||||
regex: RegExp;
|
||||
format: ArchiveFormat;
|
||||
/** A fixed format, or one derived from the match for patterns spanning several formats. */
|
||||
format: ArchiveFormat | ((match: RegExpMatchArray) => ArchiveFormat);
|
||||
pattern: MultipartInfo["pattern"];
|
||||
getBaseName: (match: RegExpMatchArray) => string;
|
||||
getPartNumber: (match: RegExpMatchArray) => number;
|
||||
}[] = [
|
||||
// pack.zip.001, pack.zip.002
|
||||
// pack.zip.001, pack.rar.001, pack.7z.001 (numbered volume split — one pattern for
|
||||
// every format, so a new format can never be silently dropped for lack of its own entry).
|
||||
// {2,} digits also picks up hand-renamed sets like pack.rar.01.
|
||||
{
|
||||
regex: /^(.+\.zip)\.(\d{3,})$/i,
|
||||
format: "ZIP",
|
||||
pattern: "ZIP_NUMBERED",
|
||||
getBaseName: (m) => m[1],
|
||||
getPartNumber: (m) => parseInt(m[2], 10),
|
||||
regex: /^(.+\.(zip|7z|rar))\.(\d{2,})$/i,
|
||||
// The regex only ever captures zip/7z/rar, so uppercasing yields a valid ArchiveFormat.
|
||||
format: (m) => m[2].toUpperCase() as ArchiveFormat,
|
||||
pattern: "ARCHIVE_NUMBERED",
|
||||
getBaseName: (m) => m[1], // includes the archive extension
|
||||
getPartNumber: (m) => parseInt(m[3], 10),
|
||||
},
|
||||
// pack.z01, pack.z02 (legacy split — final part is pack.zip)
|
||||
// pack.z01, pack.z02 (legacy split — pack.zip is the FINAL disk of the set)
|
||||
{
|
||||
regex: /^(.+)\.z(\d{2,})$/i,
|
||||
format: "ZIP",
|
||||
@@ -30,15 +34,16 @@ const patterns: {
|
||||
getBaseName: (m) => m[1],
|
||||
getPartNumber: (m) => parseInt(m[2], 10),
|
||||
},
|
||||
// pack.part1.rar, pack.part2.rar
|
||||
// pack.part1.rar, pack.part2.rar — .exe covers a self-extracting first volume
|
||||
// (pack.part1.exe + pack.part2.rar + ...), which is still a RAR volume set.
|
||||
{
|
||||
regex: /^(.+)\.part(\d+)\.rar$/i,
|
||||
regex: /^(.+)\.part(\d+)\.(rar|exe)$/i,
|
||||
format: "RAR",
|
||||
pattern: "RAR_PART",
|
||||
getBaseName: (m) => m[1],
|
||||
getPartNumber: (m) => parseInt(m[2], 10),
|
||||
},
|
||||
// pack.r00, pack.r01 (legacy split — final part is pack.rar)
|
||||
// pack.r00, pack.r01 (legacy split — pack.rar is the FIRST volume, .r00 onwards follow it)
|
||||
{
|
||||
regex: /^(.+)\.r(\d{2,})$/i,
|
||||
format: "RAR",
|
||||
@@ -48,40 +53,51 @@ const patterns: {
|
||||
},
|
||||
];
|
||||
|
||||
/** Extensions we recognize as fetchable documents (archives + standalone files) */
|
||||
const DOCUMENT_EXTENSIONS = /\.(pdf|stl|obj|3mf|step|stp|blend|gcode|svg|dxf|ai|eps|psd)$/i;
|
||||
/** Extensions we recognize as fetchable documents (archives + standalone files).
|
||||
* Deliberately excludes image formats — previews posted as uncompressed documents
|
||||
* must go through the photo-matching path, not become packages of their own. */
|
||||
const DOCUMENT_EXTENSIONS =
|
||||
/\.(pdf|stl|obj|3mf|step|stp|blend1|blend|gcode|svg|dxf|ai|eps|psd|lys|lyt|lymesh|chitubox|ctp|ctb|cbddlp|photon|pwmx|pwmo|pws|sl1|goo|phz|pm3|fbx|ply|glb|gltf|3ds|max|c4d|ztl|zpr|mtl|f3d|scad|igs|iges|sldprt|form|skp|wrl)$/i;
|
||||
|
||||
/**
|
||||
* Detect if a filename is an archive and extract multipart info.
|
||||
*/
|
||||
export function detectArchive(fileName: string): MultipartInfo | null {
|
||||
// TDLib hands us `document.file_name` verbatim and every pattern below is `$`-anchored,
|
||||
// so "Pack.zip " or "Pack.zip." would otherwise be dropped without a trace. Trailing dots
|
||||
// are stripped too: no filesystem or archiver can produce a meaningful one (Windows
|
||||
// silently drops them), so a trailing dot is always cosmetic damage from a re-upload,
|
||||
// never part of the real name. The normalized value is used for matching AND baseName.
|
||||
const name = fileName.trim().replace(/[.\s]+$/, "");
|
||||
if (!name) return null;
|
||||
|
||||
// Check multipart patterns first
|
||||
for (const p of patterns) {
|
||||
const match = fileName.match(p.regex);
|
||||
const match = name.match(p.regex);
|
||||
if (match) {
|
||||
return {
|
||||
baseName: p.getBaseName(match),
|
||||
partNumber: p.getPartNumber(match),
|
||||
format: p.format,
|
||||
format: typeof p.format === "function" ? p.format(match) : p.format,
|
||||
pattern: p.pattern,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Single .zip file — could be a standalone or the final part of a ZIP_LEGACY set
|
||||
if (/\.zip$/i.test(fileName)) {
|
||||
if (/\.zip$/i.test(name)) {
|
||||
return {
|
||||
baseName: fileName.replace(/\.zip$/i, ""),
|
||||
baseName: name.replace(/\.zip$/i, ""),
|
||||
partNumber: -1, // -1 signals "could be single or final legacy part"
|
||||
format: "ZIP",
|
||||
pattern: "SINGLE",
|
||||
};
|
||||
}
|
||||
|
||||
// Single .rar file — could be standalone or final part of RAR_LEGACY set
|
||||
if (/\.rar$/i.test(fileName)) {
|
||||
// Single .rar file — could be standalone or the FIRST part of a RAR_LEGACY set
|
||||
if (/\.rar$/i.test(name)) {
|
||||
return {
|
||||
baseName: fileName.replace(/\.rar$/i, ""),
|
||||
baseName: name.replace(/\.rar$/i, ""),
|
||||
partNumber: -1,
|
||||
format: "RAR",
|
||||
pattern: "SINGLE",
|
||||
@@ -89,20 +105,19 @@ export function detectArchive(fileName: string): MultipartInfo | null {
|
||||
}
|
||||
|
||||
// Single .7z file
|
||||
if (/\.7z$/i.test(fileName)) {
|
||||
if (/\.7z$/i.test(name)) {
|
||||
return {
|
||||
baseName: fileName.replace(/\.7z$/i, ""),
|
||||
baseName: name.replace(/\.7z$/i, ""),
|
||||
partNumber: -1,
|
||||
format: "7Z",
|
||||
pattern: "SINGLE",
|
||||
};
|
||||
}
|
||||
|
||||
// Standalone documents (PDFs, STLs, 3D files, etc.)
|
||||
if (DOCUMENT_EXTENSIONS.test(fileName)) {
|
||||
const ext = fileName.match(DOCUMENT_EXTENSIONS)![0];
|
||||
// Standalone documents (PDFs, STLs, 3D files, slicer projects, etc.)
|
||||
if (DOCUMENT_EXTENSIONS.test(name)) {
|
||||
return {
|
||||
baseName: fileName.replace(DOCUMENT_EXTENSIONS, ""),
|
||||
baseName: name.replace(DOCUMENT_EXTENSIONS, ""),
|
||||
partNumber: -1,
|
||||
format: "DOCUMENT",
|
||||
pattern: "SINGLE",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createHash } from "crypto";
|
||||
import { deriveForwardContentHash } from "./forward-identity.js";
|
||||
import type { FileEntry } from "./zip-reader.js";
|
||||
|
||||
function entry(crc32: string | null): FileEntry {
|
||||
return { path: "a", fileName: "a", extension: null, compressedSize: 1n, uncompressedSize: 1n, crc32 };
|
||||
}
|
||||
|
||||
describe("deriveForwardContentHash", () => {
|
||||
it("hashes the sorted CRC list when all entries have a CRC32 (ZIP/RAR)", () => {
|
||||
const entries = [entry("BBBB"), entry("AAAA")];
|
||||
const expectedHash = createHash("sha256").update(["aaaa", "bbbb"].join(",")).digest("hex");
|
||||
expect(deriveForwardContentHash(entries, "unique-1", "chan-1", 42n)).toBe(`fingerprint:${expectedHash}`);
|
||||
});
|
||||
|
||||
it("falls back to remoteUniqueId when CRCs are incomplete (7z today)", () => {
|
||||
const entries = [entry(null), entry("AAAA")];
|
||||
expect(deriveForwardContentHash(entries, "unique-42", "chan-1", 42n)).toBe("forward:unique-42");
|
||||
});
|
||||
|
||||
it("falls back to sourceChannelId+sourceMessageId when there's no CRC and no remoteUniqueId", () => {
|
||||
const entries = [entry(null)];
|
||||
expect(deriveForwardContentHash(entries, null, "chan-1", 42n)).toBe("forward:chan-1:42");
|
||||
});
|
||||
|
||||
it("falls back past an empty entries list the same way", () => {
|
||||
expect(deriveForwardContentHash([], null, "chan-1", 7n)).toBe("forward:chan-1:7");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHash } from "crypto";
|
||||
import { crcFingerprint } from "./fingerprint.js";
|
||||
import type { FileEntry } from "./zip-reader.js";
|
||||
|
||||
/**
|
||||
* Derive a Package.contentHash-compatible identity string for a forward-path
|
||||
* package (no downloaded bytes exist to hash directly). Priority order:
|
||||
* 1. A CRC32-fingerprint hash, when the ranged listing's CRCs are complete
|
||||
* (ZIP/RAR today) — the strongest available signal, since it lets
|
||||
* forward-path and download-path copies of the same archive still
|
||||
* collide/dedupe on identical content.
|
||||
* 2. TDLib's remote.unique_id, when CRCs are incomplete (7z today has none).
|
||||
* 3. sourceChannelId+sourceMessageId, as a last-resort unique value so the
|
||||
* required-unique Package.contentHash column is always satisfiable.
|
||||
* Follows the same `<prefix>:<value>` synthetic-hash convention already used
|
||||
* by `rebuild.ts`'s `rebuild:${destChannelId}:${destMessageId}` placeholder.
|
||||
*/
|
||||
export function deriveForwardContentHash(
|
||||
entries: FileEntry[],
|
||||
remoteUniqueId: string | null,
|
||||
sourceChannelId: string,
|
||||
sourceMessageId: bigint,
|
||||
): string {
|
||||
const fp = crcFingerprint(entries);
|
||||
if (fp.complete && fp.crcs.length > 0) {
|
||||
const hash = createHash("sha256").update(fp.crcs.join(",")).digest("hex");
|
||||
return `fingerprint:${hash}`;
|
||||
}
|
||||
if (remoteUniqueId) {
|
||||
return `forward:${remoteUniqueId}`;
|
||||
}
|
||||
return `forward:${sourceChannelId}:${sourceMessageId}`;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
const candidate = {
|
||||
id: "pkg-1", archiveType: "ZIP", fileName: "a.zip", fileCount: 3, fileSize: 100n,
|
||||
destMessageId: 1n, destMessageIds: [1n], destChannel: { telegramId: 999n },
|
||||
};
|
||||
|
||||
vi.mock("../db/queries.js", () => ({
|
||||
findFingerprintDedupCandidates: vi.fn(async () => [candidate]),
|
||||
}));
|
||||
const resolveMock = vi.fn(async (..._args: unknown[]) => [{ path: "x", fileName: "x", extension: null, compressedSize: 1n, uncompressedSize: 1n, crc32: "AAAA" }]);
|
||||
const compareMock = vi.fn();
|
||||
vi.mock("../provenance-backfill.js", () => ({
|
||||
resolveCandidateFingerprintEntries: (...args: unknown[]) => resolveMock(...args),
|
||||
compareFingerprints: (...args: unknown[]) => compareMock(...args),
|
||||
}));
|
||||
|
||||
import { checkFingerprintRepost } from "./forward-repost-check.js";
|
||||
import type { FileEntry } from "./zip-reader.js";
|
||||
|
||||
const newEntries: FileEntry[] = [{ path: "x", fileName: "x", extension: null, compressedSize: 1n, uncompressedSize: 1n, crc32: "AAAA" }];
|
||||
|
||||
describe("checkFingerprintRepost", () => {
|
||||
it("reports a duplicate when a candidate's fingerprint matches", async () => {
|
||||
compareMock.mockReturnValueOnce("match");
|
||||
const result = await checkFingerprintRepost({} as never, newEntries, "a.zip", 100n);
|
||||
expect(result).toEqual({ isDuplicate: true, matchedPackageId: "pkg-1" });
|
||||
});
|
||||
|
||||
it("reports no duplicate when no candidate matches", async () => {
|
||||
compareMock.mockReturnValueOnce("mismatch");
|
||||
const result = await checkFingerprintRepost({} as never, newEntries, "a.zip", 100n);
|
||||
expect(result).toEqual({ isDuplicate: false, matchedPackageId: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Client } from "tdl";
|
||||
import type { FileEntry } from "./zip-reader.js";
|
||||
import { compareFingerprints, resolveCandidateFingerprintEntries } from "../provenance-backfill.js";
|
||||
import { findFingerprintDedupCandidates } from "../db/queries.js";
|
||||
|
||||
export interface FingerprintRepostResult {
|
||||
isDuplicate: boolean;
|
||||
matchedPackageId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-channel duplicate check for the forward-priority path: compare the
|
||||
* new archive's CRC fingerprint against every existing Package sharing its
|
||||
* name+size, regardless of which channel or ingestion path produced them.
|
||||
* This is what lets a forwarded copy dedupe against a previously
|
||||
* fully-downloaded copy of the same archive, despite never sharing a
|
||||
* byte-hash-derived contentHash.
|
||||
*/
|
||||
export async function checkFingerprintRepost(
|
||||
client: Client,
|
||||
entries: FileEntry[],
|
||||
fileName: string,
|
||||
fileSize: bigint,
|
||||
): Promise<FingerprintRepostResult> {
|
||||
const candidates = await findFingerprintDedupCandidates(fileName, fileSize);
|
||||
for (const candidate of candidates) {
|
||||
const candidateEntries = await resolveCandidateFingerprintEntries(client, candidate);
|
||||
if (compareFingerprints(entries, candidateEntries) === "match") {
|
||||
return { isDuplicate: true, matchedPackageId: candidate.id };
|
||||
}
|
||||
}
|
||||
return { isDuplicate: false, matchedPackageId: null };
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
planListingRead,
|
||||
planRangedFallback,
|
||||
classifySourceShape,
|
||||
isConcatRepackName,
|
||||
concatRepackBase,
|
||||
concatChunkIndex,
|
||||
isVolumeSet,
|
||||
} from "./listing-plan.js";
|
||||
|
||||
const GB = 1024n * 1024n * 1024n;
|
||||
|
||||
/** Defaults for a plan input; individual tests override what they care about. */
|
||||
function input(over: Partial<Parameters<typeof planListingRead>[0]>) {
|
||||
return {
|
||||
archiveType: "ZIP",
|
||||
sourceFileName: "Pack.z01",
|
||||
destFileNames: ["Pack.z01", "Pack.zip"],
|
||||
totalSize: 10n * GB,
|
||||
maxDownloadBytes: 200n * GB,
|
||||
rangedOnly: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("classifySourceShape", () => {
|
||||
it("separates spanned ZIP volumes from a raw byte split", () => {
|
||||
expect(classifySourceShape("Pack.z01")).toBe("spanned-zip");
|
||||
expect(classifySourceShape("Pack.z12")).toBe("spanned-zip");
|
||||
expect(classifySourceShape("Pack.zip.001")).toBe("byte-split");
|
||||
expect(classifySourceShape("Pack.7z.001")).toBe("byte-split");
|
||||
});
|
||||
|
||||
it("recognizes RAR volume sets and lone archives", () => {
|
||||
expect(classifySourceShape("Pack.part1.rar")).toBe("rar-volume-set");
|
||||
expect(classifySourceShape("Pack.r00")).toBe("rar-volume-set");
|
||||
expect(classifySourceShape("Pack.zip")).toBe("single");
|
||||
expect(classifySourceShape("notes.txt")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("marks exactly the layouts whose volumes are independent containers", () => {
|
||||
expect(isVolumeSet("spanned-zip")).toBe(true);
|
||||
expect(isVolumeSet("rar-volume-set")).toBe(true);
|
||||
expect(isVolumeSet("byte-split")).toBe(false);
|
||||
expect(isVolumeSet("single")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("concat repack naming", () => {
|
||||
it("recognizes the repack chunk names the uploader produces", () => {
|
||||
expect(isConcatRepackName("Pack.concat.001")).toBe(true);
|
||||
expect(isConcatRepackName("Pack.concat.017")).toBe(true);
|
||||
expect(isConcatRepackName("Pack.concat")).toBe(true);
|
||||
expect(isConcatRepackName("Pack.z01")).toBe(false);
|
||||
expect(isConcatRepackName("Pack.zip.001")).toBe(false);
|
||||
// "concat" appearing mid-name must not count
|
||||
expect(isConcatRepackName("concat-models.zip")).toBe(false);
|
||||
});
|
||||
|
||||
it("groups and orders chunks of one repack", () => {
|
||||
expect(concatRepackBase("Pack.concat.002")).toBe("pack.concat");
|
||||
expect(concatRepackBase("Pack.concat")).toBe("pack.concat");
|
||||
expect(concatChunkIndex("Pack.concat.017")).toBe(17);
|
||||
expect(concatChunkIndex("Pack.concat")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planListingRead", () => {
|
||||
it("routes a spanned ZIP set to the ranged read", () => {
|
||||
const plan = planListingRead(input({}));
|
||||
expect(plan.route).toBe("ranged");
|
||||
expect(plan.reason).toContain("spanned-zip");
|
||||
});
|
||||
|
||||
it("skips a concatenated spanned ZIP set — no reader can ever list it", () => {
|
||||
const plan = planListingRead(
|
||||
input({ sourceFileName: "Pack.z01", destFileNames: ["Pack.concat.001", "Pack.concat.002"] })
|
||||
);
|
||||
expect(plan.route).toBe("skip");
|
||||
expect(plan.reason).toContain("not a valid archive");
|
||||
});
|
||||
|
||||
it("skips a concatenated RAR volume set for the same reason", () => {
|
||||
const plan = planListingRead(
|
||||
input({
|
||||
archiveType: "RAR",
|
||||
sourceFileName: "Pack.part1.rar",
|
||||
destFileNames: ["Pack.concat.001", "Pack.concat.002"],
|
||||
})
|
||||
);
|
||||
expect(plan.route).toBe("skip");
|
||||
expect(plan.reason).toContain("rar-volume-set");
|
||||
});
|
||||
|
||||
it("still reads a concatenated BYTE SPLIT — re-cutting one stream is lossless", () => {
|
||||
const plan = planListingRead(
|
||||
input({ sourceFileName: "Pack.zip.001", destFileNames: ["Pack.concat.001", "Pack.concat.002"] })
|
||||
);
|
||||
expect(plan.route).toBe("ranged");
|
||||
expect(plan.reason).toContain("byte split");
|
||||
});
|
||||
|
||||
it("skips archive types with no file-list reader without touching the API", () => {
|
||||
expect(planListingRead(input({ archiveType: "DOCUMENT" }))).toMatchObject({ route: "skip" });
|
||||
});
|
||||
|
||||
it("skips when no destination part could be resolved", () => {
|
||||
expect(planListingRead(input({ destFileNames: [] }))).toMatchObject({ route: "skip" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("planRangedFallback", () => {
|
||||
it("refuses to download when rangedOnly is set, however large the archive", () => {
|
||||
const plan = planRangedFallback({ totalSize: 35n * GB, maxDownloadBytes: 200n * GB, rangedOnly: true });
|
||||
expect(plan.route).toBe("skip");
|
||||
expect(plan.reason).toContain("rangedOnly");
|
||||
});
|
||||
|
||||
it("refuses to download past the size cap", () => {
|
||||
const plan = planRangedFallback({ totalSize: 300n * GB, maxDownloadBytes: 200n * GB, rangedOnly: false });
|
||||
expect(plan.route).toBe("skip");
|
||||
expect(plan.reason).toContain("exceeds the download cap");
|
||||
});
|
||||
|
||||
it("falls back to a download when it is allowed and affordable", () => {
|
||||
const plan = planRangedFallback({ totalSize: 2n * GB, maxDownloadBytes: 200n * GB, rangedOnly: false });
|
||||
expect(plan.route).toBe("download");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { detectArchive } from "./detect.js";
|
||||
|
||||
/**
|
||||
* Decide *how* to read an already-uploaded archive's inner-file listing before
|
||||
* spending a single byte of Telegram traffic on it.
|
||||
*
|
||||
* Three outcomes matter:
|
||||
*
|
||||
* - `ranged` — read only the header/tail bytes that carry the listing.
|
||||
* Tens of kilobytes regardless of archive size.
|
||||
* - `download` — the ranged read can't work (or already failed); pull the whole
|
||||
* archive down and let the on-disk reader handle it.
|
||||
* - `skip` — no reader can ever list this destination copy. Saying so
|
||||
* up-front is the whole point: the alternative is burning API
|
||||
* calls and bandwidth on something structurally unreadable.
|
||||
*
|
||||
* The `skip` case that motivated this module: when any source volume exceeded
|
||||
* the upload cap, the ingestion worker concatenated every volume into one file
|
||||
* and re-split it into uniform `<base>.concat.NNN` chunks. For a *byte split*
|
||||
* (`pack.zip.001`, …) that round-trips fine — the bytes are the same stream.
|
||||
* For a ZIP-spec **spanned** set (`pack.z01`, …, `pack.zip`) or a RAR volume
|
||||
* set it does not: those volumes are separate containers, and their
|
||||
* concatenation is not a valid archive in any format. The destination copy of
|
||||
* such a package is permanently unlistable, and no amount of downloading will
|
||||
* change that.
|
||||
*/
|
||||
|
||||
/** `<base>.concat`, `<base>.concat.001`, … — the re-split repack naming. */
|
||||
const CONCAT_REPACK_RE = /\.concat(?:\.\d{2,})?$/i;
|
||||
|
||||
export function isConcatRepackName(fileName: string): boolean {
|
||||
return CONCAT_REPACK_RE.test(fileName.trim().replace(/[.\s]+$/, ""));
|
||||
}
|
||||
|
||||
/** Strip the `.NNN` chunk suffix, so every chunk of one repack shares a key. */
|
||||
export function concatRepackBase(fileName: string): string {
|
||||
return fileName
|
||||
.trim()
|
||||
.replace(/[.\s]+$/, "")
|
||||
.replace(/\.(\d{2,})$/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Numeric chunk index of a `<base>.concat.NNN` name; 0 for a bare `.concat`. */
|
||||
export function concatChunkIndex(fileName: string): number {
|
||||
const m = fileName.trim().replace(/[.\s]+$/, "").match(/\.(\d{2,})$/);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the *source* archive was laid out, derived from the Package's own
|
||||
* fileName. This is what decides whether a `.concat.NNN` repack is survivable:
|
||||
* only a stream that was contiguous to begin with can be re-cut.
|
||||
*/
|
||||
export type SourceShape =
|
||||
/** `.z01`, `.z02`, … + `.zip` — ZIP-spec spanned, one container per volume. */
|
||||
| "spanned-zip"
|
||||
/** `.partN.rar` or `.rNN` — RAR volume set, one container per volume. */
|
||||
| "rar-volume-set"
|
||||
/** `.zip.001`, `.7z.001`, … — one file cut into chunks. */
|
||||
| "byte-split"
|
||||
/** A lone `.zip` / `.rar` / `.7z` / document. */
|
||||
| "single"
|
||||
| "unknown";
|
||||
|
||||
export function classifySourceShape(fileName: string): SourceShape {
|
||||
const info = detectArchive(fileName);
|
||||
if (!info) return "unknown";
|
||||
switch (info.pattern) {
|
||||
case "ZIP_LEGACY":
|
||||
return "spanned-zip";
|
||||
case "RAR_PART":
|
||||
case "RAR_LEGACY":
|
||||
return "rar-volume-set";
|
||||
case "ARCHIVE_NUMBERED":
|
||||
return "byte-split";
|
||||
case "SINGLE":
|
||||
return "single";
|
||||
}
|
||||
}
|
||||
|
||||
/** True for layouts whose volumes are independent containers, not one stream. */
|
||||
export function isVolumeSet(shape: SourceShape): boolean {
|
||||
return shape === "spanned-zip" || shape === "rar-volume-set";
|
||||
}
|
||||
|
||||
export type ListingRoute =
|
||||
| { route: "ranged"; reason: string }
|
||||
| { route: "download"; reason: string }
|
||||
| { route: "skip"; reason: string };
|
||||
|
||||
export interface ListingPlanInput {
|
||||
/** Package.archiveType. */
|
||||
archiveType: string;
|
||||
/** Package.fileName — the *source* name, which encodes the original layout. */
|
||||
sourceFileName: string;
|
||||
/** File names of the resolved destination parts, in upload order. */
|
||||
destFileNames: string[];
|
||||
/** Total size of the destination parts. */
|
||||
totalSize: bigint;
|
||||
/** Cap on a full download (WORKER_MAX_ZIP_SIZE_MB, in bytes). */
|
||||
maxDownloadBytes: bigint;
|
||||
/** When true, the full-download fallback is off the table entirely. */
|
||||
rangedOnly: boolean;
|
||||
}
|
||||
|
||||
const RANGED_TYPES = new Set(["ZIP", "RAR", "SEVEN_Z"]);
|
||||
|
||||
/**
|
||||
* Pick the first route to try for a package. `download` is only returned when
|
||||
* ranged reading is structurally impossible for the type; a ranged read that
|
||||
* fails at runtime is handled by {@link planRangedFallback}.
|
||||
*/
|
||||
export function planListingRead(input: ListingPlanInput): ListingRoute {
|
||||
if (!RANGED_TYPES.has(input.archiveType)) {
|
||||
return { route: "skip", reason: `archiveType ${input.archiveType} has no file-list reader` };
|
||||
}
|
||||
if (input.destFileNames.length === 0) {
|
||||
return { route: "skip", reason: "no destination parts could be resolved" };
|
||||
}
|
||||
|
||||
const shape = classifySourceShape(input.sourceFileName);
|
||||
const repacked = input.destFileNames.some(isConcatRepackName);
|
||||
|
||||
if (repacked && isVolumeSet(shape)) {
|
||||
return {
|
||||
route: "skip",
|
||||
reason:
|
||||
`destination copy is a .concat.NNN repack of a ${shape} — concatenated volumes ` +
|
||||
"are not a valid archive, so no reader can ever list it",
|
||||
};
|
||||
}
|
||||
|
||||
if (repacked) {
|
||||
// A re-cut byte split is still the original contiguous stream, so the
|
||||
// ranged reader's whole-archive offset arithmetic applies unchanged.
|
||||
return { route: "ranged", reason: `.concat.NNN repack of a ${shape} — readable as a byte split` };
|
||||
}
|
||||
|
||||
return { route: "ranged", reason: `${shape} destination copy, ${input.destFileNames.length} part(s)` };
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do once a ranged read has come back empty. Kept separate from
|
||||
* {@link planListingRead} so the "we tried cheap and it didn't work" decision
|
||||
* is explicit and testable rather than buried in a conditional.
|
||||
*/
|
||||
export function planRangedFallback(
|
||||
input: Pick<ListingPlanInput, "totalSize" | "maxDownloadBytes" | "rangedOnly">
|
||||
): { route: "download"; reason: string } | { route: "skip"; reason: string } {
|
||||
if (input.rangedOnly) {
|
||||
return {
|
||||
route: "skip",
|
||||
reason: `ranged read failed and rangedOnly is set — not downloading ${input.totalSize} bytes`,
|
||||
};
|
||||
}
|
||||
if (input.totalSize > input.maxDownloadBytes) {
|
||||
return {
|
||||
route: "skip",
|
||||
reason: `ranged read failed and ${input.totalSize} bytes exceeds the download cap of ${input.maxDownloadBytes}`,
|
||||
};
|
||||
}
|
||||
return { route: "download", reason: `ranged read failed — falling back to a ${input.totalSize} byte download` };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { groupArchiveSets, type TelegramMessage } from "./multipart.js";
|
||||
|
||||
let nextId = 1000n;
|
||||
|
||||
function msg(fileName: string): TelegramMessage {
|
||||
const id = nextId++;
|
||||
return {
|
||||
id,
|
||||
fileName,
|
||||
fileId: `file-${id}`,
|
||||
fileSize: 1024n,
|
||||
date: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function names(files: string[]): string[] {
|
||||
const sets = groupArchiveSets(files.map(msg));
|
||||
expect(sets).toHaveLength(1);
|
||||
return sets[0].parts.map((p) => p.fileName);
|
||||
}
|
||||
|
||||
describe("groupArchiveSets — legacy split part ordering", () => {
|
||||
it("puts the bare .rar FIRST in a RAR_LEGACY set (it is volume 1)", () => {
|
||||
expect(names(["Pack.r01", "Pack.rar", "Pack.r00"])).toEqual([
|
||||
"Pack.rar",
|
||||
"Pack.r00",
|
||||
"Pack.r01",
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts the bare .zip LAST in a ZIP_LEGACY set (it is the final disk)", () => {
|
||||
expect(names(["Pack.z02", "Pack.zip", "Pack.z01"])).toEqual([
|
||||
"Pack.z01",
|
||||
"Pack.z02",
|
||||
"Pack.zip",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks both legacy sets as multipart with the right format", () => {
|
||||
const rar = groupArchiveSets([msg("Pack.rar"), msg("Pack.r00")])[0];
|
||||
expect(rar.isMultipart).toBe(true);
|
||||
expect(rar.type).toBe("RAR");
|
||||
|
||||
const zip = groupArchiveSets([msg("Pack.zip"), msg("Pack.z01")])[0];
|
||||
expect(zip.isMultipart).toBe(true);
|
||||
expect(zip.type).toBe("ZIP");
|
||||
});
|
||||
|
||||
it("orders numbered volume sets by part number", () => {
|
||||
expect(names(["Pack.rar.003", "Pack.rar.001", "Pack.rar.002"])).toEqual([
|
||||
"Pack.rar.001",
|
||||
"Pack.rar.002",
|
||||
"Pack.rar.003",
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders .partN sets by part number with an SFX first volume", () => {
|
||||
expect(names(["Pack.part3.rar", "Pack.part1.exe", "Pack.part2.rar"])).toEqual([
|
||||
"Pack.part1.exe",
|
||||
"Pack.part2.rar",
|
||||
"Pack.part3.rar",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats unrelated singles as their own non-multipart sets", () => {
|
||||
const sets = groupArchiveSets([msg("A.zip"), msg("B.rar")]);
|
||||
expect(sets).toHaveLength(2);
|
||||
expect(sets.every((s) => !s.isMultipart)).toBe(true);
|
||||
expect(sets.every((s) => s.parts.length === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -78,10 +78,17 @@ export function groupArchiveSets(messages: TelegramMessage[]): ArchiveSet[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by part number (singles get a very high number so they come last — they're the final part)
|
||||
// Sort by part number. A bare single (partNumber -1) sits at a different end of the
|
||||
// set depending on the legacy scheme: in a .zip/.z01/.z02 set the bare pack.zip is the
|
||||
// FINAL disk, but in a .rar/.r00/.r01 set the bare pack.rar is volume 1 and .r00
|
||||
// onwards follow it. Getting this backwards makes parts[0] a headerless continuation
|
||||
// volume, which breaks listing and mislabels the package.
|
||||
const singleRank = multipartEntries.some((e) => e.info.pattern === "RAR_LEGACY")
|
||||
? -1 // before .r00
|
||||
: 999999;
|
||||
allEntries.sort((a, b) => {
|
||||
const aNum = a.info.partNumber === -1 ? 999999 : a.info.partNumber;
|
||||
const bNum = b.info.partNumber === -1 ? 999999 : b.info.partNumber;
|
||||
const aNum = a.info.partNumber === -1 ? singleRank : a.info.partNumber;
|
||||
const bNum = b.info.partNumber === -1 ? singleRank : b.info.partNumber;
|
||||
return aNum - bNum;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readScannedListingRanged, readScannedZipListing } from "./dispatch.js";
|
||||
import type { RangeReader } from "./range-reader.js";
|
||||
import { buildSpannedStoreZip, buildStoreZip, byteSplit } from "../testing/spanned-zip-fixture.js";
|
||||
|
||||
/** Serve ranged reads out of in-memory part buffers keyed by fileId. */
|
||||
function readerFor(parts: { fileId: string; buf: Buffer }[]): RangeReader {
|
||||
const byId = new Map(parts.map((p) => [p.fileId, p.buf]));
|
||||
return async (fileId, offset, length) => {
|
||||
const buf = byId.get(fileId);
|
||||
if (!buf) throw new Error(`unknown fileId ${fileId}`);
|
||||
return buf.subarray(offset, offset + length);
|
||||
};
|
||||
}
|
||||
|
||||
const FILES = [
|
||||
{ name: "src/b.bin", data: Buffer.alloc(2048, 7), disk: 0 },
|
||||
{ name: "src/models/dragon.stl", data: Buffer.from("DRAGON"), disk: 1 },
|
||||
{ name: "readme.txt", data: Buffer.from("hello world"), disk: 2 },
|
||||
];
|
||||
|
||||
describe("readScannedListingRanged", () => {
|
||||
it("returns null for an unknown archive type without calling the reader", async () => {
|
||||
const result = await readScannedListingRanged(
|
||||
"DOCUMENT",
|
||||
{ invoke: async () => ({}) } as never,
|
||||
[{ fileId: "1", fileSize: 100n, fileName: "a.pdf" }],
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readScannedZipListing", () => {
|
||||
it("lists a ZIP-spec spanned set (.z01 … .zip) from the final volume's tail", async () => {
|
||||
const vols = buildSpannedStoreZip(FILES, 3);
|
||||
const names = ["Pack.z01", "Pack.z02", "Pack.zip"];
|
||||
const parts = vols.map((buf, i) => ({ fileId: String(i), fileSize: BigInt(buf.length), fileName: names[i] }));
|
||||
|
||||
const entries = await readScannedZipListing(
|
||||
parts,
|
||||
readerFor(parts.map((p, i) => ({ fileId: p.fileId, buf: vols[i] }))),
|
||||
);
|
||||
|
||||
expect(entries).not.toBeNull();
|
||||
expect(entries!.map((e) => e.path).sort()).toEqual([
|
||||
"readme.txt",
|
||||
"src/b.bin",
|
||||
"src/models/dragon.stl",
|
||||
]);
|
||||
});
|
||||
|
||||
it("only reads the final volume — the earlier volumes are never downloaded", async () => {
|
||||
const vols = buildSpannedStoreZip(FILES, 3);
|
||||
const names = ["Pack.z01", "Pack.z02", "Pack.zip"];
|
||||
const parts = vols.map((buf, i) => ({ fileId: String(i), fileSize: BigInt(buf.length), fileName: names[i] }));
|
||||
const touched: string[] = [];
|
||||
const base = readerFor(parts.map((p, i) => ({ fileId: p.fileId, buf: vols[i] })));
|
||||
|
||||
await readScannedZipListing(parts, async (id, off, len, size) => {
|
||||
touched.push(id);
|
||||
return base(id, off, len, size);
|
||||
});
|
||||
|
||||
expect([...new Set(touched)]).toEqual(["2"]);
|
||||
});
|
||||
|
||||
it("still lists a 7-Zip raw byte split (.zip.001 …) using whole-archive offsets", async () => {
|
||||
const zip = buildStoreZip([
|
||||
{ name: "models/knight.stl", data: Buffer.alloc(5000, 9) },
|
||||
{ name: "license.txt", data: Buffer.from("MIT") },
|
||||
]);
|
||||
const chunks = byteSplit(zip, 3);
|
||||
const names = ["Pack.zip.001", "Pack.zip.002", "Pack.zip.003"];
|
||||
const parts = chunks.map((buf, i) => ({ fileId: String(i), fileSize: BigInt(buf.length), fileName: names[i] }));
|
||||
|
||||
const entries = await readScannedZipListing(
|
||||
parts,
|
||||
readerFor(parts.map((p, i) => ({ fileId: p.fileId, buf: chunks[i] }))),
|
||||
);
|
||||
|
||||
expect(entries!.map((e) => e.fileName).sort()).toEqual(["knight.stl", "license.txt"]);
|
||||
});
|
||||
|
||||
it("returns null for a spanned set whose central directory starts on an earlier volume", async () => {
|
||||
const vols = buildSpannedStoreZip(
|
||||
FILES.map((f) => ({ ...f, disk: Math.min(f.disk, 1) })),
|
||||
3,
|
||||
{ cdStartDisk: 1 },
|
||||
);
|
||||
const names = ["Pack.z01", "Pack.z02", "Pack.zip"];
|
||||
const parts = vols.map((buf, i) => ({ fileId: String(i), fileSize: BigInt(buf.length), fileName: names[i] }));
|
||||
|
||||
const entries = await readScannedZipListing(
|
||||
parts,
|
||||
readerFor(parts.map((p, i) => ({ fileId: p.fileId, buf: vols[i] }))),
|
||||
);
|
||||
expect(entries).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when there are no parts", async () => {
|
||||
expect(await readScannedZipListing([], readerFor([]))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Client } from "tdl";
|
||||
import { parseZipCentralDirectoryFromTail, findEocdOffset, MIN_ZIP_TAIL_BYTES } from "../central-directory.js";
|
||||
import { isSpannedZipPartSet } from "../zip-spanned.js";
|
||||
import { childLogger } from "../../util/logger.js";
|
||||
import type { FileEntry } from "../zip-reader.js";
|
||||
import { readSevenZListingRanged, type RangedPart } from "./sevenz-ranged.js";
|
||||
import { readRarListingRanged } from "./rar-ranged.js";
|
||||
import { tdlibRangeReader, type RangeReader } from "./range-reader.js";
|
||||
|
||||
const log = childLogger("ranged-dispatch");
|
||||
|
||||
/**
|
||||
* Read a ZIP central directory from the tail of a (possibly multipart)
|
||||
* archive. `parts` is ordered; only the LAST part carries the EOCD record.
|
||||
* `fileSize` on each part is that part's own size (NOT the whole-archive
|
||||
* total) so the download offset stays within that part's bounds.
|
||||
*
|
||||
* Which logical offset the EOCD's central-directory pointer is measured from
|
||||
* depends on the multipart shape:
|
||||
*
|
||||
* - 7-Zip raw byte split (`.zip.001`, …): the parts are one ZIP file cut into
|
||||
* chunks, so the pointer is a whole-archive offset → `tailStart` is the
|
||||
* preceding parts' sizes plus the offset within the last part.
|
||||
* - ZIP-spec spanned archive (`.z01`, …, `.zip`): each volume is its own unit
|
||||
* and the pointer is relative to the start of the volume holding the
|
||||
* directory → `tailStart` is just the offset within that final volume.
|
||||
*
|
||||
* Getting this wrong makes the computed directory offset wildly negative, and
|
||||
* the parser then throws RangeError on every tail size — which is exactly how
|
||||
* spanned sets ended up indexed with no file list at all.
|
||||
*/
|
||||
export async function readScannedZipListing(
|
||||
parts: RangedPart[],
|
||||
read: RangeReader,
|
||||
): Promise<FileEntry[] | null> {
|
||||
if (parts.length === 0) return null;
|
||||
const lastPart = parts[parts.length - 1];
|
||||
const spanned = isSpannedZipPartSet(parts.map((p) => p.fileName));
|
||||
const precedingSize = spanned
|
||||
? 0
|
||||
: parts.slice(0, -1).reduce((sum, p) => sum + Number(p.fileSize), 0);
|
||||
const lastSize = Number(lastPart.fileSize);
|
||||
for (const tailBytes of [MIN_ZIP_TAIL_BYTES, MIN_ZIP_TAIL_BYTES * 4]) {
|
||||
const partOffset = Math.max(0, lastSize - tailBytes);
|
||||
const downloadLen = Math.min(tailBytes, lastSize);
|
||||
try {
|
||||
const buf = await read(lastPart.fileId, partOffset, downloadLen, lastPart.fileSize);
|
||||
if (spanned && !cdStartsOnFinalVolume(buf)) {
|
||||
// The directory begins on an earlier volume; reaching it would mean
|
||||
// ranged-reading that volume too. Leave it to the full-download path.
|
||||
log.debug({ fileId: lastPart.fileId }, "spanned ZIP central directory is not on the final volume");
|
||||
return null;
|
||||
}
|
||||
return parseZipCentralDirectoryFromTail(buf, partOffset + precedingSize);
|
||||
} catch (err) {
|
||||
if (err instanceof RangeError) continue; // try a larger tail
|
||||
log.warn({ err, fileId: lastPart.fileId }, "ranged ZIP listing failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a spanned archive, whether the EOCD says the central directory starts on
|
||||
* the very volume that EOCD lives on (the usual case). ZIP64's saturated
|
||||
* 0xFFFF disk fields are treated as "yes" — the ZIP64 record that supersedes
|
||||
* them is itself in this tail, and the parser resolves it there.
|
||||
*/
|
||||
function cdStartsOnFinalVolume(tail: Buffer): boolean {
|
||||
const eocd = findEocdOffset(tail);
|
||||
if (eocd < 0) return true; // let the parser report the real problem
|
||||
const thisDisk = tail.readUInt16LE(eocd + 4);
|
||||
const cdStartDisk = tail.readUInt16LE(eocd + 6);
|
||||
return thisDisk === cdStartDisk || thisDisk === 0xffff || cdStartDisk === 0xffff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a (no-download) inner-file listing read by archive type. Used both
|
||||
* by the provenance-backfill path (reading an already-uploaded copy) and the
|
||||
* forward-priority ingestion path (reading the source channel's copy before
|
||||
* any download/forward decision is made) — the read itself only needs
|
||||
* {fileId, fileSize, fileName}, so it doesn't matter which channel the file
|
||||
* currently lives in.
|
||||
*/
|
||||
export async function readScannedListingRanged(
|
||||
archiveType: string,
|
||||
client: Client,
|
||||
parts: RangedPart[],
|
||||
): Promise<FileEntry[] | null> {
|
||||
const read = tdlibRangeReader(client);
|
||||
if (archiveType === "ZIP") return readScannedZipListing(parts, read);
|
||||
if (archiveType === "SEVEN_Z") return readSevenZListingRanged(parts, read);
|
||||
if (archiveType === "RAR") return readRarListingRanged(parts, read);
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readVint, detectRarSignature, parseRar5BlockExtent, parseRar4BlockExtent, walkRarVolume, readRarListingRanged } from "./rar-ranged.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { RangeReader } from "./range-reader.js";
|
||||
|
||||
let capturedFirstBytes: Buffer | null = null;
|
||||
vi.mock("../rar-reader.js", () => ({
|
||||
readRarContents: async (firstPartPath: string) => {
|
||||
const { readFile } = await import("fs/promises");
|
||||
const reconstructed = await readFile(firstPartPath);
|
||||
capturedFirstBytes = reconstructed.subarray(0, 8);
|
||||
return [{ name: "dummy", size: 0 }]; // non-empty so listFromSparse returns it
|
||||
},
|
||||
}));
|
||||
|
||||
const { readVint, detectRarSignature, parseRar5BlockExtent, parseRar4BlockExtent, walkRarVolume, readRarListingRanged } =
|
||||
await import("./rar-ranged.js");
|
||||
|
||||
describe("readVint", () => {
|
||||
it("reads single-byte and multi-byte values (base-128 LE)", () => {
|
||||
expect(readVint(Buffer.from([0x08]), 0)).toEqual({ value: 8, bytes: 1 });
|
||||
@@ -123,6 +135,23 @@ describe("readRarListingRanged (single part)", () => {
|
||||
const res = await readRarListingRanged([{ fileId: "1", fileSize: BigInt(vol.length), fileName: "a.rar" }], read);
|
||||
expect(res === null || Array.isArray(res)).toBe(true); // real unrar parse covered live
|
||||
});
|
||||
|
||||
it("preserves the RAR signature bytes in the reconstructed sparse file", async () => {
|
||||
// Regression test: walkRarVolume starts at pos = sigLen and never
|
||||
// harvests the signature itself. If readRarListingRanged forgets to add
|
||||
// it as its own region, the reconstructed file starts with zero bytes
|
||||
// instead of "Rar!\x1a\x07\x01\x00", and every real unrar invocation
|
||||
// rejects it as "not RAR archive" — silently forcing every RAR archive
|
||||
// through the expensive download+reupload fallback regardless of the
|
||||
// channel's forwarding permission.
|
||||
const vol = buildRar5Volume();
|
||||
const sig = vol.subarray(0, 8);
|
||||
const read: RangeReader = async (_id, offset, length) => vol.subarray(offset, offset + length);
|
||||
capturedFirstBytes = null;
|
||||
await readRarListingRanged([{ fileId: "1", fileSize: BigInt(vol.length), fileName: "a.rar" }], read);
|
||||
expect(capturedFirstBytes).not.toBeNull();
|
||||
expect(capturedFirstBytes).toEqual(sig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readRarListingRanged (multipart)", () => {
|
||||
|
||||
@@ -69,11 +69,17 @@ export async function walkRarVolume(
|
||||
let blocks = 0;
|
||||
try {
|
||||
while (pos < size) {
|
||||
if (++blocks > MAX_RAR_BLOCKS) return null;
|
||||
if (++blocks > MAX_RAR_BLOCKS) {
|
||||
rlog.warn({ fileId: part.fileId, fileName: part.fileName, blocks }, "RAR walk aborted — exceeded MAX_RAR_BLOCKS");
|
||||
return null;
|
||||
}
|
||||
const chunkLen = Math.min(HEADER_CHUNK, size - pos);
|
||||
let chunk = await read(part.fileId, pos, chunkLen, part.fileSize);
|
||||
const ext = version === 5 ? parseRar5BlockExtent(chunk, 0) : parseRar4BlockExtent(chunk, 0);
|
||||
if (ext.headerBytes > MAX_RAR_HEADER_BYTES) return null;
|
||||
if (ext.headerBytes > MAX_RAR_HEADER_BYTES) {
|
||||
rlog.warn({ fileId: part.fileId, fileName: part.fileName, pos, headerBytes: ext.headerBytes }, "RAR walk aborted — headerBytes exceeded MAX_RAR_HEADER_BYTES");
|
||||
return null;
|
||||
}
|
||||
// Ensure we have the full header bytes to harvest (long filenames).
|
||||
let headerBuf = chunk;
|
||||
if (ext.headerBytes > chunk.length) {
|
||||
@@ -82,13 +88,16 @@ export async function walkRarVolume(
|
||||
regions.push({ offset: pos, bytes: headerBuf.subarray(0, Math.min(ext.headerBytes, size - pos)) });
|
||||
if (ext.isEnd) break;
|
||||
const advance = ext.headerBytes + ext.dataSize;
|
||||
if (advance <= 0) return null;
|
||||
if (advance <= 0) {
|
||||
rlog.warn({ fileId: part.fileId, fileName: part.fileName, pos, headerBytes: ext.headerBytes, dataSize: ext.dataSize }, "RAR walk aborted — non-positive advance");
|
||||
return null;
|
||||
}
|
||||
if (pos + advance > size) break; // data clamped at the volume boundary (multipart continuation)
|
||||
pos += advance;
|
||||
}
|
||||
return regions;
|
||||
} catch (err) {
|
||||
rlog.warn({ err, fileId: part.fileId }, "RAR volume walk failed");
|
||||
rlog.warn({ err, fileId: part.fileId, fileName: part.fileName, pos }, "RAR volume walk failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -101,10 +110,18 @@ export async function readRarListingRanged(
|
||||
for (const part of parts) {
|
||||
const head = await read(part.fileId, 0, 16, part.fileSize);
|
||||
const sig = detectRarSignature(head);
|
||||
if (!sig) return null;
|
||||
if (!sig) {
|
||||
rlog.warn({ fileId: part.fileId, fileName: part.fileName, head: head.toString("hex") }, "RAR signature not detected at offset 0");
|
||||
return null;
|
||||
}
|
||||
const regions = await walkRarVolume(read, part, sig.version, sig.sigLen);
|
||||
if (!regions) return null;
|
||||
sparseParts.push({ fileName: part.fileName, size: Number(part.fileSize), regions });
|
||||
// walkRarVolume starts at pos = sigLen and never harvests the signature
|
||||
// itself, so it must be added as its own region — otherwise the
|
||||
// reconstructed sparse file starts with zero bytes instead of the "Rar!"
|
||||
// magic, and unrar rejects it outright as "not RAR archive".
|
||||
const sigRegion = { offset: 0, bytes: head.subarray(0, sig.sigLen) };
|
||||
sparseParts.push({ fileName: part.fileName, size: Number(part.fileSize), regions: [sigRegion, ...regions] });
|
||||
}
|
||||
return listFromSparse(sparseParts, readRarContents);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,250 @@ describe("readSevenZListingRanged", () => {
|
||||
});
|
||||
});
|
||||
|
||||
import { read7zNumber, locate7zEncodedHeaderPack } from "./sevenz-ranged.js";
|
||||
import { read7zNumber, locate7zEncodedHeaderPack, mapRangeToVolumes, planSevenZSparseParts } from "./sevenz-ranged.js";
|
||||
import type { RangedPart } from "./sevenz-ranged.js";
|
||||
import type { SparsePart } from "./sparse-list.js";
|
||||
|
||||
describe("mapRangeToVolumes", () => {
|
||||
it("maps a range fully inside one volume", () => {
|
||||
expect(mapRangeToVolumes([100, 100, 100], 120, 30)).toEqual([
|
||||
{ partIndex: 1, offset: 20, length: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits a range that straddles a volume boundary", () => {
|
||||
expect(mapRangeToVolumes([100, 100], 90, 20)).toEqual([
|
||||
{ partIndex: 0, offset: 90, length: 10 },
|
||||
{ partIndex: 1, offset: 0, length: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("spans three volumes when the range swallows a whole middle volume", () => {
|
||||
expect(mapRangeToVolumes([100, 50, 100], 90, 80)).toEqual([
|
||||
{ partIndex: 0, offset: 90, length: 10 },
|
||||
{ partIndex: 1, offset: 0, length: 50 },
|
||||
{ partIndex: 2, offset: 0, length: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("degenerates to a single-volume identity mapping", () => {
|
||||
expect(mapRangeToVolumes([5_000_000], 4_900_000, 100)).toEqual([
|
||||
{ partIndex: 0, offset: 4_900_000, length: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null when the range runs past the concatenated end", () => {
|
||||
expect(mapRangeToVolumes([100, 100], 190, 20)).toBeNull();
|
||||
expect(mapRangeToVolumes([100], 100, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for negative offsets or lengths", () => {
|
||||
expect(mapRangeToVolumes([100], -1, 10)).toBeNull();
|
||||
expect(mapRangeToVolumes([100], 10, -1)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns no slices for a zero-length range", () => {
|
||||
expect(mapRangeToVolumes([100, 100], 150, 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A `.7z.001`/`.7z.002` set is a raw byte split of one logical 7z file, so
|
||||
* fixtures are built as a single logical stream and then cut into volumes.
|
||||
* No real `7z` binary is needed (and none is installed here) because the
|
||||
* contract under test is the whole-archive-offset mapping, not `7z l` parsing.
|
||||
*/
|
||||
function buildLogical7z(opts: {
|
||||
total: number;
|
||||
nextHeaderStart: number; // absolute offset in the logical stream
|
||||
nextHeader: Buffer;
|
||||
pack?: { start: number; bytes: Buffer }; // absolute offset of packed header bytes
|
||||
}): Buffer {
|
||||
const buf = Buffer.alloc(opts.total, 0x5a); // 0x5a stands in for file payload
|
||||
MAGIC.copy(buf, 0);
|
||||
buf.writeUInt8(0, 6); buf.writeUInt8(4, 7);
|
||||
buf.writeUInt32LE(0, 8);
|
||||
buf.writeBigUInt64LE(BigInt(opts.nextHeaderStart - 32), 12); // NextHeaderOffset
|
||||
buf.writeBigUInt64LE(BigInt(opts.nextHeader.length), 20); // NextHeaderSize
|
||||
buf.writeUInt32LE(0, 28);
|
||||
opts.nextHeader.copy(buf, opts.nextHeaderStart);
|
||||
if (opts.pack) opts.pack.bytes.copy(buf, opts.pack.start);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function splitIntoVolumes(logical: Buffer, sizes: number[]): Buffer[] {
|
||||
const out: Buffer[] = [];
|
||||
let pos = 0;
|
||||
for (const s of sizes) { out.push(logical.subarray(pos, pos + s)); pos += s; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function volumeSet(volumes: Buffer[]): {
|
||||
parts: RangedPart[];
|
||||
read: RangeReader;
|
||||
reads: { fileId: string; offset: number; length: number }[];
|
||||
} {
|
||||
const parts = volumes.map((v, i) => ({
|
||||
fileId: `v${i + 1}`,
|
||||
fileSize: BigInt(v.length),
|
||||
fileName: `pack.7z.${String(i + 1).padStart(3, "0")}`,
|
||||
}));
|
||||
const reads: { fileId: string; offset: number; length: number }[] = [];
|
||||
const read: RangeReader = async (fileId, offset, length) => {
|
||||
reads.push({ fileId, offset, length });
|
||||
const vol = volumes[parts.findIndex((p) => p.fileId === fileId)];
|
||||
return Buffer.from(vol.subarray(offset, offset + length));
|
||||
};
|
||||
return { parts, read, reads };
|
||||
}
|
||||
|
||||
/** Rebuild the logical stream from the sparse per-volume reconstructions. */
|
||||
function reconstruct(sparseParts: SparsePart[]): Buffer {
|
||||
return Buffer.concat(
|
||||
sparseParts.map((p) => {
|
||||
const b = Buffer.alloc(p.size);
|
||||
for (const r of p.regions) r.bytes.copy(b, r.offset);
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("readSevenZListingRanged — multi-volume (.7z.001, .7z.002, ...)", () => {
|
||||
it("reads the next header from the LAST volume, not the first", async () => {
|
||||
// Volume 1 is 200 bytes; the index sits at logical 280 — inside volume 2.
|
||||
const nextHeader = Buffer.concat([Buffer.from([0x01]), Buffer.alloc(19, 0x11)]);
|
||||
const logical = buildLogical7z({ total: 300, nextHeaderStart: 280, nextHeader });
|
||||
const { parts, read, reads } = volumeSet(splitIntoVolumes(logical, [200, 100]));
|
||||
|
||||
const sparse = await planSevenZSparseParts(parts, read);
|
||||
expect(sparse).not.toBeNull();
|
||||
|
||||
expect(reads).toEqual([
|
||||
{ fileId: "v1", offset: 0, length: 32 }, // signature header: volume 1
|
||||
{ fileId: "v2", offset: 80, length: 20 }, // next header: volume 2 @ 280-200
|
||||
]);
|
||||
// Both volumes are reconstructed so `7z l pack.7z.001` can concatenate them.
|
||||
expect(sparse!.map((p) => [p.fileName, p.size])).toEqual([
|
||||
["pack.7z.001", 200],
|
||||
["pack.7z.002", 100],
|
||||
]);
|
||||
const rebuilt = reconstruct(sparse!);
|
||||
expect(rebuilt.length).toBe(300);
|
||||
expect(rebuilt.subarray(0, 32).equals(logical.subarray(0, 32))).toBe(true);
|
||||
expect(rebuilt.subarray(280, 300).equals(nextHeader)).toBe(true);
|
||||
});
|
||||
|
||||
it("fetches an encoded header's packed bytes from whichever middle volume holds them", async () => {
|
||||
// 3 volumes of 100. Packed header bytes at logical 150 (volume 2), index at 270 (volume 3).
|
||||
// kEncodedHeader, kPackInfo, PackPos=118, NumStreams=1, kSize, PackSize=24
|
||||
const encHeader = Buffer.from([0x17, 0x06, 0x76, 0x01, 0x09, 0x18]);
|
||||
const packBytes = Buffer.alloc(24, 0x77);
|
||||
const logical = buildLogical7z({
|
||||
total: 300,
|
||||
nextHeaderStart: 270,
|
||||
nextHeader: encHeader,
|
||||
pack: { start: 150, bytes: packBytes },
|
||||
});
|
||||
const { parts, read, reads } = volumeSet(splitIntoVolumes(logical, [100, 100, 100]));
|
||||
|
||||
const sparse = await planSevenZSparseParts(parts, read);
|
||||
expect(sparse).not.toBeNull();
|
||||
expect(reads).toEqual([
|
||||
{ fileId: "v1", offset: 0, length: 32 },
|
||||
{ fileId: "v3", offset: 70, length: 6 }, // index in the last volume
|
||||
{ fileId: "v2", offset: 50, length: 24 }, // packed header in the middle volume
|
||||
]);
|
||||
expect(sparse!).toHaveLength(3);
|
||||
const rebuilt = reconstruct(sparse!);
|
||||
expect(rebuilt.subarray(150, 174).equals(packBytes)).toBe(true);
|
||||
expect(rebuilt.subarray(270, 276).equals(encHeader)).toBe(true);
|
||||
});
|
||||
|
||||
it("splits a header range that straddles a volume boundary into two reads", async () => {
|
||||
// Index is 40 bytes starting at logical 180: last 20 bytes of volume 2 (100..200)
|
||||
// and first 20 bytes of volume 3.
|
||||
const nextHeader = Buffer.concat([Buffer.from([0x01]), Buffer.alloc(39, 0x22)]);
|
||||
const logical = buildLogical7z({ total: 300, nextHeaderStart: 180, nextHeader });
|
||||
const { parts, read, reads } = volumeSet(splitIntoVolumes(logical, [100, 100, 100]));
|
||||
|
||||
const sparse = await planSevenZSparseParts(parts, read);
|
||||
expect(sparse).not.toBeNull();
|
||||
expect(reads).toEqual([
|
||||
{ fileId: "v1", offset: 0, length: 32 },
|
||||
{ fileId: "v2", offset: 80, length: 20 },
|
||||
{ fileId: "v3", offset: 0, length: 20 },
|
||||
]);
|
||||
// Byte-exact across the seam.
|
||||
expect(reconstruct(sparse!).subarray(180, 220).equals(nextHeader)).toBe(true);
|
||||
});
|
||||
|
||||
it("splits an encoded header's packed bytes across a volume boundary", async () => {
|
||||
// PackPos=58 -> packStart 90, PackSize=30 -> 90..120 straddles volumes 1|2.
|
||||
const encHeader = Buffer.from([0x17, 0x06, 0x3a, 0x01, 0x09, 0x1e]);
|
||||
const packBytes = Buffer.alloc(30, 0x99);
|
||||
const logical = buildLogical7z({
|
||||
total: 300,
|
||||
nextHeaderStart: 290,
|
||||
nextHeader: encHeader,
|
||||
pack: { start: 90, bytes: packBytes },
|
||||
});
|
||||
const { parts, read, reads } = volumeSet(splitIntoVolumes(logical, [100, 100, 100]));
|
||||
|
||||
const sparse = await planSevenZSparseParts(parts, read);
|
||||
expect(sparse).not.toBeNull();
|
||||
expect(reads).toEqual([
|
||||
{ fileId: "v1", offset: 0, length: 32 },
|
||||
{ fileId: "v3", offset: 90, length: 6 },
|
||||
{ fileId: "v1", offset: 90, length: 10 },
|
||||
{ fileId: "v2", offset: 0, length: 20 },
|
||||
]);
|
||||
expect(reconstruct(sparse!).subarray(90, 120).equals(packBytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a single-volume .7z reading exactly as before", async () => {
|
||||
const nextHeader = Buffer.concat([Buffer.from([0x01]), Buffer.alloc(19, 0x33)]);
|
||||
const logical = buildLogical7z({ total: 300, nextHeaderStart: 280, nextHeader });
|
||||
const { parts, read, reads } = volumeSet([logical]);
|
||||
|
||||
const sparse = await planSevenZSparseParts(parts, read);
|
||||
expect(reads).toEqual([
|
||||
{ fileId: "v1", offset: 0, length: 32 },
|
||||
{ fileId: "v1", offset: 280, length: 20 },
|
||||
]);
|
||||
expect(sparse!).toHaveLength(1);
|
||||
expect(sparse![0].size).toBe(300);
|
||||
expect(reconstruct(sparse!).subarray(280, 300).equals(nextHeader)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null when the next-header offset points past the whole set", async () => {
|
||||
const nextHeader = Buffer.from([0x01, 0x00]);
|
||||
// Claim the index lives at 5000 while the set totals only 300 bytes.
|
||||
const logical = buildLogical7z({ total: 300, nextHeaderStart: 280, nextHeader });
|
||||
logical.writeBigUInt64LE(BigInt(5000 - 32), 12);
|
||||
const { parts, read } = volumeSet(splitIntoVolumes(logical, [200, 100]));
|
||||
expect(await planSevenZSparseParts(parts, read)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on an unrecognized next-header type", async () => {
|
||||
const logical = buildLogical7z({
|
||||
total: 300,
|
||||
nextHeaderStart: 280,
|
||||
nextHeader: Buffer.from([0x42, 0x00]),
|
||||
});
|
||||
const { parts, read } = volumeSet(splitIntoVolumes(logical, [200, 100]));
|
||||
expect(await planSevenZSparseParts(parts, read)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the first volume has no 7z signature", async () => {
|
||||
const logical = Buffer.alloc(300, 0x00);
|
||||
const { parts, read } = volumeSet(splitIntoVolumes(logical, [200, 100]));
|
||||
expect(await planSevenZSparseParts(parts, read)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty part list", async () => {
|
||||
expect(await planSevenZSparseParts([], async () => Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("read7zNumber", () => {
|
||||
it("reads a single-byte number", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FileEntry } from "../zip-reader.js";
|
||||
import { read7zContents } from "../sevenz-reader.js";
|
||||
import { listFromSparse } from "./sparse-list.js";
|
||||
import { listFromSparse, type SparsePart } from "./sparse-list.js";
|
||||
import type { RangeReader } from "./range-reader.js";
|
||||
import { childLogger } from "../../util/logger.js";
|
||||
|
||||
@@ -13,6 +13,8 @@ const K_ENCODED_HEADER = 0x17;
|
||||
const K_PACK_INFO = 0x06;
|
||||
const K_SIZE = 0x09;
|
||||
|
||||
const SIG_HEADER_BYTES = 32;
|
||||
|
||||
/** Read a 7z variable-length number: first byte is a length mask, followed by
|
||||
* little-endian bytes. Math.pow keeps values exact above 2^31. */
|
||||
export function read7zNumber(buf: Buffer, pos: number): { value: number; next: number } {
|
||||
@@ -61,7 +63,7 @@ export function locate7zEncodedHeaderPack(
|
||||
export function parseSevenZSignatureHeader(
|
||||
buf: Buffer,
|
||||
): { nextHeaderOffset: number; nextHeaderSize: number } | null {
|
||||
if (buf.length < 32) return null;
|
||||
if (buf.length < SIG_HEADER_BYTES) return null;
|
||||
if (!buf.subarray(0, 6).equals(SEVENZ_MAGIC)) return null;
|
||||
return {
|
||||
nextHeaderOffset: Number(buf.readBigUInt64LE(12)),
|
||||
@@ -71,42 +73,158 @@ export function parseSevenZSignatureHeader(
|
||||
|
||||
export interface RangedPart { fileId: string; fileSize: bigint; fileName: string }
|
||||
|
||||
/** One volume's share of a whole-archive byte range. */
|
||||
export interface VolumeSlice { partIndex: number; offset: number; length: number }
|
||||
|
||||
/**
|
||||
* A `.7z.001`/`.7z.002`/… set produced by 7-Zip's `-v` switch is a **raw byte
|
||||
* split** of one logical `.7z` file, not a ZIP-style spanned archive with
|
||||
* per-volume structure: `cat pack.7z.00*` reproduces the original archive
|
||||
* byte-for-byte. So every offset in the 7z headers is an offset into the
|
||||
* concatenation of the volumes, and the only correct way to read them is to
|
||||
* treat the set as one logical byte stream.
|
||||
*
|
||||
* That matters most for the next header (the archive index), which a 7z file
|
||||
* keeps at its *end* — i.e. in the **last** volume, never the first. Reading
|
||||
* only `parts[0]` therefore fails for every multi-volume set.
|
||||
*
|
||||
* Map a whole-archive `[start, start + length)` range onto per-volume reads,
|
||||
* splitting it when it straddles a volume boundary. Returns null when the
|
||||
* range falls outside the concatenated stream.
|
||||
*/
|
||||
export function mapRangeToVolumes(
|
||||
sizes: number[],
|
||||
start: number,
|
||||
length: number,
|
||||
): VolumeSlice[] | null {
|
||||
const total = sizes.reduce((sum, s) => sum + s, 0);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(length)) return null;
|
||||
if (start < 0 || length < 0 || start + length > total) return null;
|
||||
|
||||
const slices: VolumeSlice[] = [];
|
||||
let pos = start;
|
||||
let remaining = length;
|
||||
let base = 0;
|
||||
for (let i = 0; i < sizes.length && remaining > 0; i++) {
|
||||
const end = base + sizes[i];
|
||||
if (pos < end) {
|
||||
const offset = pos - base;
|
||||
const take = Math.min(remaining, sizes[i] - offset);
|
||||
if (take > 0) {
|
||||
slices.push({ partIndex: i, offset, length: take });
|
||||
pos += take;
|
||||
remaining -= take;
|
||||
}
|
||||
}
|
||||
base = end;
|
||||
}
|
||||
return remaining === 0 ? slices : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the 7z header regions of a (possibly multi-volume) archive and return
|
||||
* them as per-volume sparse reconstructions — the header bytes at their real
|
||||
* offsets, file payloads left as zero holes.
|
||||
*
|
||||
* Exported for testing: it is the whole-archive-offset mapping that is worth
|
||||
* asserting, and the final `7z l` step needs the real binary.
|
||||
*/
|
||||
export async function planSevenZSparseParts(
|
||||
parts: RangedPart[],
|
||||
read: RangeReader,
|
||||
): Promise<SparsePart[] | null> {
|
||||
const first = parts[0];
|
||||
if (!first) {
|
||||
log.warn({ partCount: parts.length }, "ranged 7z listing aborted — no parts supplied");
|
||||
return null;
|
||||
}
|
||||
const sizes = parts.map((p) => Number(p.fileSize));
|
||||
const total = sizes.reduce((sum, s) => sum + s, 0);
|
||||
// Log context shared by every bail-out below; the first part names the set.
|
||||
const ctx = { fileId: first.fileId, fileName: first.fileName, partCount: parts.length, total };
|
||||
const regions: { offset: number; bytes: Buffer }[][] = parts.map(() => []);
|
||||
|
||||
/**
|
||||
* Read a whole-archive range, recording each volume's slice as a region so
|
||||
* the sparse reconstruction places the bytes where 7z expects them.
|
||||
*/
|
||||
const fetchLogical = async (start: number, length: number, what: string): Promise<Buffer | null> => {
|
||||
const slices = mapRangeToVolumes(sizes, start, length);
|
||||
if (!slices) {
|
||||
log.warn({ ...ctx, what, start, length, sizes }, `ranged 7z listing aborted — ${what} region out of bounds`);
|
||||
return null;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
for (const s of slices) {
|
||||
const part = parts[s.partIndex];
|
||||
const bytes = await read(part.fileId, s.offset, s.length, part.fileSize);
|
||||
if (bytes.length < s.length) {
|
||||
log.warn(
|
||||
{ ...ctx, what, volume: s.partIndex + 1, volumeFileId: part.fileId, offset: s.offset, wanted: s.length, got: bytes.length },
|
||||
`ranged 7z listing aborted — short read on ${what}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
regions[s.partIndex].push({ offset: s.offset, bytes });
|
||||
chunks.push(bytes);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
};
|
||||
|
||||
try {
|
||||
// The signature header is at the very start of volume 1.
|
||||
const sig = await fetchLogical(0, Math.min(SIG_HEADER_BYTES, total), "signature-header");
|
||||
if (!sig) return null;
|
||||
const parsed = parseSevenZSignatureHeader(sig);
|
||||
if (!parsed) {
|
||||
log.warn({ ...ctx, head: sig.subarray(0, 16).toString("hex") }, "ranged 7z listing aborted — signature header did not parse");
|
||||
return null;
|
||||
}
|
||||
|
||||
// NextHeaderOffset is measured from the end of the signature header, into
|
||||
// the concatenated stream — so this normally lands in the LAST volume.
|
||||
const endStart = SIG_HEADER_BYTES + parsed.nextHeaderOffset;
|
||||
if (parsed.nextHeaderSize <= 0) {
|
||||
log.warn({ ...ctx, endStart, nextHeaderSize: parsed.nextHeaderSize }, "ranged 7z listing aborted — empty next header (no index to read)");
|
||||
return null;
|
||||
}
|
||||
const endHeader = await fetchLogical(endStart, parsed.nextHeaderSize, "next-header");
|
||||
if (!endHeader) return null;
|
||||
|
||||
const headerType = endHeader[0];
|
||||
if (headerType === K_ENCODED_HEADER) {
|
||||
// Compressed header: its packed bytes live mid-stream, not at EOF, so
|
||||
// they may sit in any volume — or straddle two.
|
||||
const pack = locate7zEncodedHeaderPack(endHeader);
|
||||
if (!pack) {
|
||||
log.warn(
|
||||
{ ...ctx, endStart, nextHeaderSize: parsed.nextHeaderSize, head: endHeader.subarray(0, 16).toString("hex") },
|
||||
"ranged 7z listing aborted — encoded-header PackInfo did not parse",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const packStart = SIG_HEADER_BYTES + pack.packPos;
|
||||
const packBytes = await fetchLogical(packStart, pack.packSize, "packed-header");
|
||||
if (!packBytes) return null;
|
||||
} else if (headerType !== K_HEADER) {
|
||||
log.warn({ ...ctx, endStart, headerType }, "ranged 7z listing aborted — unknown next-header type");
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts.map((p, i) => ({ fileName: p.fileName, size: sizes[i], regions: regions[i] }));
|
||||
} catch (err) {
|
||||
log.warn({ err, ...ctx }, "ranged 7z listing failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSevenZListingRanged(
|
||||
parts: RangedPart[],
|
||||
read: RangeReader,
|
||||
): Promise<FileEntry[] | null> {
|
||||
const part = parts[0];
|
||||
if (!part) return null;
|
||||
const size = Number(part.fileSize);
|
||||
try {
|
||||
const sig = await read(part.fileId, 0, 32, part.fileSize);
|
||||
const parsed = parseSevenZSignatureHeader(sig);
|
||||
if (!parsed) return null;
|
||||
const endStart = 32 + parsed.nextHeaderOffset;
|
||||
if (endStart < 0 || endStart + parsed.nextHeaderSize > size) return null;
|
||||
const endHeader = await read(part.fileId, endStart, parsed.nextHeaderSize, part.fileSize);
|
||||
|
||||
const regions = [
|
||||
{ offset: 0, bytes: sig },
|
||||
{ offset: endStart, bytes: endHeader },
|
||||
];
|
||||
|
||||
const headerType = endHeader[0];
|
||||
if (headerType === K_ENCODED_HEADER) {
|
||||
// Compressed header: its packed bytes live mid-file, not at EOF. Fetch them.
|
||||
const pack = locate7zEncodedHeaderPack(endHeader);
|
||||
if (!pack) return null;
|
||||
const packStart = 32 + pack.packPos;
|
||||
if (packStart < 0 || packStart + pack.packSize > size) return null;
|
||||
const packBytes = await read(part.fileId, packStart, pack.packSize, part.fileSize);
|
||||
regions.push({ offset: packStart, bytes: packBytes });
|
||||
} else if (headerType !== K_HEADER) {
|
||||
return null; // unknown next-header type
|
||||
}
|
||||
|
||||
return listFromSparse([{ fileName: part.fileName, size, regions }], read7zContents);
|
||||
} catch (err) {
|
||||
log.warn({ err, fileId: part.fileId }, "ranged 7z listing failed");
|
||||
return null;
|
||||
}
|
||||
const sparseParts = await planSevenZSparseParts(parts, read);
|
||||
if (!sparseParts) return null;
|
||||
// 7-Zip opens `pack.7z.001` as a split archive and concatenates the set
|
||||
// itself, so the whole reconstructed set must be on disk, not just part 1.
|
||||
return listFromSparse(sparseParts, read7zContents);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { crc32 } from "zlib"; // Node 20+ exposes zlib.crc32
|
||||
|
||||
/**
|
||||
* Test-only builders that emit real ZIP byte streams.
|
||||
*
|
||||
* Two distinct on-disk shapes are produced here, because the worker has to
|
||||
* tell them apart:
|
||||
*
|
||||
* - `buildSpannedStoreZip` → a ZIP-spec **spanned/multi-disk** archive
|
||||
* (`Pack.z01`, `Pack.z02`, …, `Pack.zip`). Each volume is its own file;
|
||||
* central-directory records carry a disk number, and the EOCD's
|
||||
* "offset of start of central directory" is relative to the *start of the
|
||||
* disk that holds it*, not to a concatenation of the volumes.
|
||||
*
|
||||
* - `buildStoreZip` → an ordinary single-file ZIP. Cutting its bytes into
|
||||
* chunks yields the 7-Zip raw byte split shape (`Pack.zip.001`, …), where
|
||||
* all disk numbers are 0 and offsets are whole-archive absolute.
|
||||
*
|
||||
* Field layouts follow APPNOTE 6.3.x sections 4.3.12 (central directory) and
|
||||
* 4.3.16 (EOCD). Verified against Info-ZIP `zip -s` output.
|
||||
*/
|
||||
|
||||
const LOCAL_SIG = 0x04034b50;
|
||||
const CD_SIG = 0x02014b50;
|
||||
const EOCD_SIG = 0x06054b50;
|
||||
/** APPNOTE 8.5.3: the first volume of a spanned archive starts with this. */
|
||||
const SPANNING_SIG = 0x08074b50;
|
||||
|
||||
export interface FixtureFile {
|
||||
name: string;
|
||||
data: Buffer;
|
||||
/** 0-based volume this file's local header + data is written to. */
|
||||
disk?: number;
|
||||
}
|
||||
|
||||
function localHeader(name: string, data: Buffer): Buffer {
|
||||
const nameBuf = Buffer.from(name, "utf8");
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(LOCAL_SIG, 0);
|
||||
local.writeUInt16LE(20, 4); // version needed
|
||||
local.writeUInt16LE(0, 6); // flags
|
||||
local.writeUInt16LE(0, 8); // method = store
|
||||
local.writeUInt32LE(crc32(data) >>> 0, 14);
|
||||
local.writeUInt32LE(data.length, 18); // compressed
|
||||
local.writeUInt32LE(data.length, 22); // uncompressed
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
local.writeUInt16LE(0, 28); // extra len
|
||||
return Buffer.concat([local, nameBuf, data]);
|
||||
}
|
||||
|
||||
function centralHeader(name: string, data: Buffer, diskStart: number, relOffset: number): Buffer {
|
||||
const nameBuf = Buffer.from(name, "utf8");
|
||||
const cd = Buffer.alloc(46);
|
||||
cd.writeUInt32LE(CD_SIG, 0);
|
||||
cd.writeUInt16LE(20, 4); // version made by
|
||||
cd.writeUInt16LE(20, 6); // version needed
|
||||
cd.writeUInt16LE(0, 8); // flags
|
||||
cd.writeUInt16LE(0, 10); // method = store
|
||||
cd.writeUInt32LE(crc32(data) >>> 0, 16);
|
||||
cd.writeUInt32LE(data.length, 20); // compressed
|
||||
cd.writeUInt32LE(data.length, 24); // uncompressed
|
||||
cd.writeUInt16LE(nameBuf.length, 28);
|
||||
cd.writeUInt16LE(diskStart, 34); // disk number start
|
||||
cd.writeUInt32LE(relOffset, 42); // offset of local header, relative to its disk
|
||||
return Buffer.concat([cd, nameBuf]);
|
||||
}
|
||||
|
||||
function eocd(opts: {
|
||||
thisDisk: number;
|
||||
cdStartDisk: number;
|
||||
entriesThisDisk: number;
|
||||
entriesTotal: number;
|
||||
cdSize: number;
|
||||
cdOffset: number;
|
||||
}): Buffer {
|
||||
const buf = Buffer.alloc(22);
|
||||
buf.writeUInt32LE(EOCD_SIG, 0);
|
||||
buf.writeUInt16LE(opts.thisDisk, 4);
|
||||
buf.writeUInt16LE(opts.cdStartDisk, 6);
|
||||
buf.writeUInt16LE(opts.entriesThisDisk, 8);
|
||||
buf.writeUInt16LE(opts.entriesTotal, 10);
|
||||
buf.writeUInt32LE(opts.cdSize, 12);
|
||||
buf.writeUInt32LE(opts.cdOffset, 16);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Build an ordinary single-file STORE ZIP (all disk numbers 0). */
|
||||
export function buildStoreZip(files: FixtureFile[]): Buffer {
|
||||
const body: Buffer[] = [];
|
||||
const central: Buffer[] = [];
|
||||
let offset = 0;
|
||||
for (const f of files) {
|
||||
const local = localHeader(f.name, f.data);
|
||||
body.push(local);
|
||||
central.push(centralHeader(f.name, f.data, 0, offset));
|
||||
offset += local.length;
|
||||
}
|
||||
const cdBuf = Buffer.concat(central);
|
||||
return Buffer.concat([
|
||||
...body,
|
||||
cdBuf,
|
||||
eocd({
|
||||
thisDisk: 0,
|
||||
cdStartDisk: 0,
|
||||
entriesThisDisk: files.length,
|
||||
entriesTotal: files.length,
|
||||
cdSize: cdBuf.length,
|
||||
cdOffset: offset,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ZIP-spec spanned archive as one Buffer per volume.
|
||||
* Returned array is volume order: [z01, z02, …, zip] (last element is the
|
||||
* final volume, which carries the central directory and EOCD).
|
||||
*
|
||||
* `cdStartDisk` (default: last volume) lets a test place the central
|
||||
* directory so that it begins on an earlier volume and spills forward,
|
||||
* exercising the cross-volume read path. As in a real writer, no file data
|
||||
* may live on a volume after the one the directory starts on — those volumes
|
||||
* hold directory continuation only.
|
||||
*/
|
||||
export function buildSpannedStoreZip(
|
||||
files: FixtureFile[],
|
||||
totalDisks: number,
|
||||
opts: { cdStartDisk?: number } = {}
|
||||
): Buffer[] {
|
||||
const cdStart = opts.cdStartDisk ?? totalDisks - 1;
|
||||
for (const f of files) {
|
||||
if ((f.disk ?? 0) > cdStart) {
|
||||
throw new Error(`fixture misuse: ${f.name} is on volume ${f.disk} but the CD starts on ${cdStart}`);
|
||||
}
|
||||
if ((f.disk ?? 0) >= totalDisks) {
|
||||
throw new Error(`fixture misuse: ${f.name} is on volume ${f.disk} of ${totalDisks}`);
|
||||
}
|
||||
}
|
||||
const chunks: Buffer[][] = Array.from({ length: totalDisks }, () => []);
|
||||
const lengths = new Array<number>(totalDisks).fill(0);
|
||||
|
||||
const marker = Buffer.alloc(4);
|
||||
marker.writeUInt32LE(SPANNING_SIG, 0);
|
||||
chunks[0].push(marker);
|
||||
lengths[0] = 4;
|
||||
|
||||
const central: Buffer[] = [];
|
||||
for (const f of files) {
|
||||
const disk = f.disk ?? 0;
|
||||
const local = localHeader(f.name, f.data);
|
||||
central.push(centralHeader(f.name, f.data, disk, lengths[disk]));
|
||||
chunks[disk].push(local);
|
||||
lengths[disk] += local.length;
|
||||
}
|
||||
|
||||
const cdBuf = Buffer.concat(central);
|
||||
const lastDisk = totalDisks - 1;
|
||||
const cdStartDisk = cdStart;
|
||||
const cdOffset = lengths[cdStartDisk];
|
||||
|
||||
// Write the CD starting on cdStartDisk, spilling onto later volumes.
|
||||
const spillDisks = lastDisk - cdStartDisk + 1;
|
||||
const firstChunkLen = Math.ceil(cdBuf.length / spillDisks);
|
||||
let written = 0;
|
||||
for (let d = cdStartDisk; d <= lastDisk; d++) {
|
||||
const take = d === lastDisk ? cdBuf.length - written : Math.min(firstChunkLen, cdBuf.length - written);
|
||||
chunks[d].push(cdBuf.subarray(written, written + take));
|
||||
lengths[d] += take;
|
||||
written += take;
|
||||
}
|
||||
|
||||
chunks[lastDisk].push(
|
||||
eocd({
|
||||
thisDisk: lastDisk,
|
||||
cdStartDisk,
|
||||
entriesThisDisk: files.length,
|
||||
entriesTotal: files.length,
|
||||
cdSize: cdBuf.length,
|
||||
cdOffset,
|
||||
})
|
||||
);
|
||||
|
||||
return chunks.map((c) => Buffer.concat(c));
|
||||
}
|
||||
|
||||
/** Cut a buffer into `count` roughly equal chunks (7-Zip raw byte split). */
|
||||
export function byteSplit(buf: Buffer, count: number): Buffer[] {
|
||||
const size = Math.ceil(buf.length / count);
|
||||
const out: Buffer[] = [];
|
||||
for (let i = 0; i < buf.length; i += size) out.push(buf.subarray(i, i + size));
|
||||
return out;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { open as fsOpen, stat as fsStat } from "fs/promises";
|
||||
import path from "path";
|
||||
import { Readable } from "stream";
|
||||
import { childLogger } from "../util/logger.js";
|
||||
import { isSpannedZipPartSet, readSpannedZipCentralDirectory } from "./zip-spanned.js";
|
||||
|
||||
const log = childLogger("zip-reader");
|
||||
|
||||
@@ -17,9 +18,15 @@ export interface FileEntry {
|
||||
|
||||
/**
|
||||
* Read the central directory of a ZIP file without extracting any contents.
|
||||
* For multipart ZIPs (.zip.001, .zip.002 etc.), uses a custom random-access
|
||||
* reader that spans all parts seamlessly so yauzl can find the central
|
||||
* directory at the end of the combined data.
|
||||
*
|
||||
* Three shapes are handled:
|
||||
* - a single `.zip` → yauzl directly;
|
||||
* - a 7-Zip raw byte split (`.zip.001`, `.zip.002`, …), which is one ZIP file
|
||||
* cut into chunks → a random-access reader that spans the chunks so yauzl
|
||||
* sees the combined stream;
|
||||
* - a ZIP-spec spanned/multi-disk archive (`.z01`, `.z02`, …, `.zip`), a
|
||||
* different on-disk format that yauzl refuses outright → a dedicated
|
||||
* volume-aware central-directory reader.
|
||||
*/
|
||||
export async function readZipCentralDirectory(
|
||||
filePaths: string[]
|
||||
@@ -28,7 +35,22 @@ export async function readZipCentralDirectory(
|
||||
return readSingleZip(filePaths[0]);
|
||||
}
|
||||
|
||||
// Multipart: use a spanning random-access reader
|
||||
if (isSpannedZipPartSet(filePaths)) {
|
||||
try {
|
||||
const result = await readSpannedZipCentralDirectory(filePaths);
|
||||
if (result.kind === "entries") return result.entries;
|
||||
if (result.kind === "failed") {
|
||||
log.warn({ reason: result.reason, parts: filePaths.length }, "Failed to read spanned ZIP");
|
||||
return [];
|
||||
}
|
||||
// "not-spanned": named like volumes but really a byte split — fall through.
|
||||
} catch (err) {
|
||||
log.warn({ err, parts: filePaths.length }, "Failed to read spanned ZIP");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Multipart byte split: use a spanning random-access reader
|
||||
return readMultipartZip(filePaths);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from "fs/promises";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { isSpannedZipPartSet } from "./zip-spanned.js";
|
||||
import { readZipCentralDirectory } from "./zip-reader.js";
|
||||
import { buildSpannedStoreZip, buildStoreZip, byteSplit } from "./testing/spanned-zip-fixture.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "zip-spanned-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Write volume buffers out under the given names and return their paths. */
|
||||
async function writeParts(names: string[], buffers: Buffer[], sub: string): Promise<string[]> {
|
||||
const base = path.join(dir, sub);
|
||||
await rm(base, { recursive: true, force: true });
|
||||
await mkdir(base, { recursive: true });
|
||||
const paths: string[] = [];
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const p = path.join(base, names[i]);
|
||||
await writeFile(p, buffers[i]);
|
||||
paths.push(p);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
const FILES = [
|
||||
{ name: "src/b.bin", data: Buffer.alloc(2048, 7), disk: 0 },
|
||||
{ name: "src/models/dragon.stl", data: Buffer.from("DRAGON"), disk: 1 },
|
||||
{ name: "src/models/", data: Buffer.alloc(0), disk: 1 },
|
||||
{ name: "src/a.bin", data: Buffer.alloc(4096, 3), disk: 2 },
|
||||
{ name: "readme.txt", data: Buffer.from("hello world"), disk: 3 },
|
||||
];
|
||||
|
||||
describe("isSpannedZipPartSet", () => {
|
||||
it("recognizes a .z01 + .zip volume set", () => {
|
||||
expect(isSpannedZipPartSet(["/t/Pack.z01", "/t/Pack.z02", "/t/Pack.zip"])).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes the set regardless of the order it is handed in", () => {
|
||||
expect(isSpannedZipPartSet(["/t/Pack.zip", "/t/Pack.z02", "/t/Pack.z01"])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a 7-Zip raw byte split (.zip.001)", () => {
|
||||
expect(isSpannedZipPartSet(["/t/Pack.zip.001", "/t/Pack.zip.002"])).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a single .zip", () => {
|
||||
expect(isSpannedZipPartSet(["/t/Pack.zip"])).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a set with no final .zip volume", () => {
|
||||
expect(isSpannedZipPartSet(["/t/Pack.z01", "/t/Pack.z02"])).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a set with two .zip volumes", () => {
|
||||
expect(isSpannedZipPartSet(["/t/A.zip", "/t/B.zip"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readZipCentralDirectory on a spanned (.z01 + .zip) set", () => {
|
||||
it("lists every entry with correct paths, sizes and crc32", async () => {
|
||||
const vols = buildSpannedStoreZip(FILES, 4);
|
||||
const paths = await writeParts(["Pack.z01", "Pack.z02", "Pack.z03", "Pack.zip"], vols, "spanned");
|
||||
|
||||
const entries = await readZipCentralDirectory(paths);
|
||||
|
||||
expect(entries.map((e) => e.path).sort()).toEqual([
|
||||
"readme.txt",
|
||||
"src/a.bin",
|
||||
"src/b.bin",
|
||||
"src/models/dragon.stl",
|
||||
]);
|
||||
const dragon = entries.find((e) => e.fileName === "dragon.stl")!;
|
||||
expect(dragon.uncompressedSize).toBe(6n);
|
||||
expect(dragon.extension).toBe("stl");
|
||||
expect(dragon.crc32).toMatch(/^[0-9a-f]{8}$/);
|
||||
const a = entries.find((e) => e.fileName === "a.bin")!;
|
||||
expect(a.uncompressedSize).toBe(4096n);
|
||||
expect(a.compressedSize).toBe(4096n);
|
||||
});
|
||||
|
||||
it("works when the parts are handed over out of volume order", async () => {
|
||||
const vols = buildSpannedStoreZip(FILES, 4);
|
||||
const paths = await writeParts(["Pack.z01", "Pack.z02", "Pack.z03", "Pack.zip"], vols, "unordered");
|
||||
const shuffled = [paths[3], paths[1], paths[0], paths[2]];
|
||||
|
||||
const entries = await readZipCentralDirectory(shuffled);
|
||||
expect(entries).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("reads a central directory that begins on an earlier volume and spills forward", async () => {
|
||||
// Files live on volumes 0–2; the directory starts on volume 2 and
|
||||
// continues onto the final .zip, which holds nothing else.
|
||||
const spillFiles = FILES.map((f) => ({ ...f, disk: Math.min(f.disk, 2) }));
|
||||
const vols = buildSpannedStoreZip(spillFiles, 4, { cdStartDisk: 2 });
|
||||
const paths = await writeParts(["Pack.z01", "Pack.z02", "Pack.z03", "Pack.zip"], vols, "spilled");
|
||||
|
||||
const entries = await readZipCentralDirectory(paths);
|
||||
expect(entries.map((e) => e.fileName).sort()).toEqual(["a.bin", "b.bin", "dragon.stl", "readme.txt"]);
|
||||
});
|
||||
|
||||
it("returns [] instead of throwing when a needed volume is missing", async () => {
|
||||
const spillFiles = FILES.map((f) => ({ ...f, disk: Math.min(f.disk, 1) }));
|
||||
const vols = buildSpannedStoreZip(spillFiles, 3, { cdStartDisk: 1 });
|
||||
const paths = await writeParts(["Pack.z01", "Pack.z02", "Pack.zip"], vols, "missing");
|
||||
// Drop Pack.z02 — the volume the central directory starts on.
|
||||
const entries = await readZipCentralDirectory([paths[0], paths[2]]);
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] instead of throwing when the final volume is garbage", async () => {
|
||||
const vols = buildSpannedStoreZip([{ name: "x.stl", data: Buffer.alloc(64, 1), disk: 0 }], 2);
|
||||
const paths = await writeParts(["Pack.z01", "Pack.zip"], [vols[0], Buffer.alloc(500, 0x5a)], "garbage");
|
||||
expect(await readZipCentralDirectory(paths)).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to concatenation semantics when .z01-named parts are really a byte split", async () => {
|
||||
// Some producers name a raw byte split .z01/.zip. The EOCD then reports
|
||||
// disk 0, so concatenation — not volume mapping — is the correct reading.
|
||||
const zip = buildStoreZip([
|
||||
{ name: "one.stl", data: Buffer.alloc(3000, 1) },
|
||||
{ name: "two.stl", data: Buffer.alloc(3000, 2) },
|
||||
]);
|
||||
const paths = await writeParts(["Pack.z01", "Pack.zip"], byteSplit(zip, 2), "mislabeled");
|
||||
const entries = await readZipCentralDirectory(paths);
|
||||
expect(entries.map((e) => e.fileName).sort()).toEqual(["one.stl", "two.stl"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readZipCentralDirectory regressions for the shapes that already worked", () => {
|
||||
it("still reads a 7-Zip raw byte split (.zip.001 …)", async () => {
|
||||
const zip = buildStoreZip([
|
||||
{ name: "models/knight.stl", data: Buffer.alloc(5000, 9) },
|
||||
{ name: "license.txt", data: Buffer.from("MIT") },
|
||||
]);
|
||||
const paths = await writeParts(
|
||||
["Pack.zip.001", "Pack.zip.002", "Pack.zip.003"],
|
||||
byteSplit(zip, 3),
|
||||
"bytesplit"
|
||||
);
|
||||
const entries = await readZipCentralDirectory(paths);
|
||||
expect(entries.map((e) => e.fileName).sort()).toEqual(["knight.stl", "license.txt"]);
|
||||
expect(entries.find((e) => e.fileName === "knight.stl")!.uncompressedSize).toBe(5000n);
|
||||
});
|
||||
|
||||
it("still reads a plain single .zip", async () => {
|
||||
const zip = buildStoreZip([{ name: "solo.stl", data: Buffer.from("SOLO") }]);
|
||||
const paths = await writeParts(["Pack.zip"], [zip], "single");
|
||||
const entries = await readZipCentralDirectory(paths);
|
||||
expect(entries.map((e) => e.fileName)).toEqual(["solo.stl"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-checks against real Info-ZIP output ────────────────────────────
|
||||
// Skipped automatically where the `zip` CLI is unavailable; the hand-built
|
||||
// fixtures above are the authoritative, portable coverage.
|
||||
|
||||
const HAS_ZIP_CLI = await execFileAsync("zip", ["-v"]).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
|
||||
/** Collect Pack.z01 … Pack.zip from a directory, in volume order. */
|
||||
async function collectVolumes(work: string): Promise<string[]> {
|
||||
const names = await readdir(work);
|
||||
const vols = names.filter((n) => /^Pack\.z\d{2,}$/i.test(n)).sort();
|
||||
const final = names.find((n) => /^Pack\.zip$/i.test(n));
|
||||
expect(final).toBeDefined();
|
||||
expect(vols.length).toBeGreaterThan(0);
|
||||
return [...vols, final!].map((n) => path.join(work, n));
|
||||
}
|
||||
|
||||
describe.skipIf(!HAS_ZIP_CLI)("readZipCentralDirectory against archives produced by Info-ZIP `zip -s`", () => {
|
||||
it("lists a genuine spanned archive", async () => {
|
||||
const work = path.join(dir, "real");
|
||||
await mkdir(path.join(work, "src/models"), { recursive: true });
|
||||
await writeFile(path.join(work, "src/big.bin"), Buffer.alloc(300_000, 4));
|
||||
await writeFile(path.join(work, "src/models/dragon.stl"), Buffer.alloc(200_000, 5));
|
||||
await writeFile(path.join(work, "src/readme.txt"), "hello world");
|
||||
await execFileAsync("zip", ["-r", "-0", "-s", "100k", "Pack.zip", "src"], { cwd: work });
|
||||
|
||||
const entries = await readZipCentralDirectory(await collectVolumes(work));
|
||||
expect(entries.map((e) => e.path).sort()).toEqual([
|
||||
"src/big.bin",
|
||||
"src/models/dragon.stl",
|
||||
"src/readme.txt",
|
||||
]);
|
||||
expect(entries.find((e) => e.fileName === "dragon.stl")!.uncompressedSize).toBe(200_000n);
|
||||
});
|
||||
|
||||
it("lists a genuine ZIP64 spanned archive", async () => {
|
||||
const work = path.join(dir, "real64");
|
||||
await mkdir(work, { recursive: true });
|
||||
await writeFile(path.join(work, "big.bin"), Buffer.alloc(300_000, 6));
|
||||
await writeFile(path.join(work, "note.txt"), "zip64 spanned");
|
||||
// -fz forces ZIP64 structures even though the payload is small.
|
||||
await execFileAsync("zip", ["-0", "-fz", "-s", "100k", "Pack.zip", "big.bin", "note.txt"], { cwd: work });
|
||||
|
||||
const entries = await readZipCentralDirectory(await collectVolumes(work));
|
||||
expect(entries.map((e) => e.fileName).sort()).toEqual(["big.bin", "note.txt"]);
|
||||
expect(entries.find((e) => e.fileName === "big.bin")!.uncompressedSize).toBe(300_000n);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { open as fsOpen, stat as fsStat } from "fs/promises";
|
||||
import path from "path";
|
||||
import { findEocdOffset, walkCentralDirectory, MIN_ZIP_TAIL_BYTES } from "./central-directory.js";
|
||||
import { childLogger } from "../util/logger.js";
|
||||
import type { FileEntry } from "./zip-reader.js";
|
||||
|
||||
const log = childLogger("zip-spanned");
|
||||
|
||||
const ZIP64_LOCATOR_SIG = 0x07064b50;
|
||||
const ZIP64_EOCD_SIG = 0x06064b50;
|
||||
|
||||
/** Refuse to allocate a buffer for an absurd central-directory size. */
|
||||
const MAX_CD_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* A `.z01`/`.z02`/…/`.zip` set is a ZIP-spec **spanned (multi-disk)** archive:
|
||||
* a genuinely different on-disk format from a 7-Zip raw byte split
|
||||
* (`.zip.001`, `.zip.002`, …), which is one ZIP file cut into chunks.
|
||||
*
|
||||
* The distinction matters because a byte split is read by concatenating the
|
||||
* chunks, whereas in a spanned archive each volume is its own unit: the EOCD's
|
||||
* central-directory offset is relative to the start of the volume that holds
|
||||
* the directory, and central-directory records carry a volume number. Feeding
|
||||
* a spanned set to a concatenating reader yields nonsense offsets (and yauzl
|
||||
* refuses outright: "multi-disk zip files are not supported").
|
||||
*
|
||||
* Detected from filename shape rather than the detector's multipart `pattern`
|
||||
* so this stays independent of how `detect.ts` labels the two variants.
|
||||
* Order-independent: the caller may hand the volumes over in any order.
|
||||
*/
|
||||
export function isSpannedZipPartSet(filePaths: string[]): boolean {
|
||||
if (filePaths.length < 2) return false;
|
||||
const names = filePaths.map((p) => path.basename(p));
|
||||
const finals = names.filter((n) => /\.zip$/i.test(n));
|
||||
const volumes = names.filter((n) => /\.z\d{2,}$/i.test(n));
|
||||
return finals.length === 1 && volumes.length === names.length - 1;
|
||||
}
|
||||
|
||||
export type SpannedZipResult =
|
||||
/** Successfully read; `entries` may legitimately be empty for an empty archive. */
|
||||
| { kind: "entries"; entries: FileEntry[] }
|
||||
/** The EOCD reports a single disk — the parts are really a byte split, so the
|
||||
* caller should fall back to reading them as one concatenated stream. */
|
||||
| { kind: "not-spanned" }
|
||||
| { kind: "failed"; reason: string };
|
||||
|
||||
/**
|
||||
* Read the central directory of a ZIP-spec spanned archive.
|
||||
*
|
||||
* Only the volume holding the central directory (plus any it spills onto) is
|
||||
* read, and only the directory bytes themselves — the file payloads are never
|
||||
* touched, so this is cheap regardless of archive size.
|
||||
*
|
||||
* Coverage: STORE/DEFLATE and ZIP64 spanned archives are handled. Archives
|
||||
* whose central directory is itself encrypted (strong encryption / "hide
|
||||
* filenames") cannot be listed by any header-only reader and return `failed`.
|
||||
*/
|
||||
export async function readSpannedZipCentralDirectory(filePaths: string[]): Promise<SpannedZipResult> {
|
||||
const volumes = new Map<number, string>();
|
||||
let finalVolume: string | undefined;
|
||||
for (const p of filePaths) {
|
||||
const base = path.basename(p);
|
||||
const m = base.match(/\.z(\d{2,})$/i);
|
||||
if (m) {
|
||||
// .z01 is volume 0, .z02 is volume 1, … (APPNOTE numbers disks from 0).
|
||||
volumes.set(parseInt(m[1], 10) - 1, p);
|
||||
} else if (/\.zip$/i.test(base)) {
|
||||
finalVolume = p;
|
||||
}
|
||||
}
|
||||
if (!finalVolume) return { kind: "failed", reason: "no final .zip volume" };
|
||||
|
||||
const finalSize = (await fsStat(finalVolume)).size;
|
||||
const tailLen = Math.min(finalSize, MIN_ZIP_TAIL_BYTES);
|
||||
const tailStart = finalSize - tailLen;
|
||||
const tail = await readBytes(finalVolume, tailStart, tailLen);
|
||||
|
||||
const eocdPos = findEocdOffset(tail);
|
||||
if (eocdPos < 0) return { kind: "failed", reason: "EOCD not found in final volume" };
|
||||
|
||||
let thisDisk = tail.readUInt16LE(eocdPos + 4);
|
||||
let cdStartDisk = tail.readUInt16LE(eocdPos + 6);
|
||||
let cdSize = tail.readUInt32LE(eocdPos + 12);
|
||||
let cdOffset = tail.readUInt32LE(eocdPos + 16);
|
||||
|
||||
// ZIP64: any saturated field means the real values live in the ZIP64 EOCD
|
||||
// record, which in a spanned archive may sit on a different volume.
|
||||
if (
|
||||
thisDisk === 0xffff ||
|
||||
cdStartDisk === 0xffff ||
|
||||
cdSize === 0xffffffff ||
|
||||
cdOffset === 0xffffffff
|
||||
) {
|
||||
const locPos = findZip64Locator(tail, eocdPos);
|
||||
if (locPos < 0) return { kind: "failed", reason: "ZIP64 locator not found" };
|
||||
const locDisk = tail.readUInt32LE(locPos + 4);
|
||||
const locOffset = Number(tail.readBigUInt64LE(locPos + 8));
|
||||
|
||||
// The locator's disk number counts the final volume too, so resolve it
|
||||
// through the same map, treating the final .zip as the highest volume.
|
||||
const z64Path =
|
||||
locDisk === thisDisk || locDisk === 0xffff ? finalVolume : volumes.get(locDisk) ?? finalVolume;
|
||||
const z64 = await readBytes(z64Path, locOffset, 56);
|
||||
if (z64.length < 56 || z64.readUInt32LE(0) !== ZIP64_EOCD_SIG) {
|
||||
return { kind: "failed", reason: "ZIP64 EOCD record unreadable" };
|
||||
}
|
||||
thisDisk = z64.readUInt32LE(16);
|
||||
cdStartDisk = z64.readUInt32LE(20);
|
||||
cdSize = Number(z64.readBigUInt64LE(40));
|
||||
cdOffset = Number(z64.readBigUInt64LE(48));
|
||||
}
|
||||
|
||||
// A byte split named .z01/.zip still reports a single disk — concatenation,
|
||||
// not volume mapping, is the correct reading for it.
|
||||
if (thisDisk === 0 && cdStartDisk === 0) return { kind: "not-spanned" };
|
||||
|
||||
if (cdSize < 0 || cdSize > MAX_CD_BYTES) {
|
||||
return { kind: "failed", reason: `implausible central directory size ${cdSize}` };
|
||||
}
|
||||
|
||||
// The final .zip file is the highest-numbered volume.
|
||||
volumes.set(thisDisk, finalVolume);
|
||||
|
||||
// Read cdSize bytes from (cdStartDisk, cdOffset), spilling onto later
|
||||
// volumes if the directory straddles a volume boundary.
|
||||
const chunks: Buffer[] = [];
|
||||
let remaining = cdSize;
|
||||
let disk = cdStartDisk;
|
||||
let offset = cdOffset;
|
||||
while (remaining > 0) {
|
||||
const volPath = volumes.get(disk);
|
||||
if (!volPath) return { kind: "failed", reason: `volume ${disk + 1} of the set is missing` };
|
||||
const chunk = await readBytes(volPath, offset, remaining);
|
||||
if (chunk.length === 0) {
|
||||
return { kind: "failed", reason: `volume ${disk + 1} ended before the central directory did` };
|
||||
}
|
||||
chunks.push(chunk);
|
||||
remaining -= chunk.length;
|
||||
disk++;
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const cdBuf = Buffer.concat(chunks);
|
||||
const entries = walkCentralDirectory(cdBuf, 0, cdSize);
|
||||
log.debug(
|
||||
{ volumes: volumes.size, thisDisk, cdStartDisk, cdSize, entries: entries.length },
|
||||
"Read spanned ZIP central directory"
|
||||
);
|
||||
return { kind: "entries", entries };
|
||||
}
|
||||
|
||||
/** Find the ZIP64 EOCD locator, which sits just before the EOCD record. */
|
||||
function findZip64Locator(tail: Buffer, eocdPos: number): number {
|
||||
for (let i = Math.min(eocdPos - 20, tail.length - 20); i >= 0; i--) {
|
||||
if (tail.readUInt32LE(i) === ZIP64_LOCATOR_SIG) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Read up to `length` bytes at `offset`; a short read means end of file. */
|
||||
async function readBytes(filePath: string, offset: number, length: number): Promise<Buffer> {
|
||||
const fh = await fsOpen(filePath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(length);
|
||||
const { bytesRead } = await fh.read(buf, 0, length, offset);
|
||||
return buf.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseBackfillPayload,
|
||||
parseFileNameLike,
|
||||
MAX_BACKFILL_LIMIT,
|
||||
DEFAULT_BACKFILL_LIMIT,
|
||||
} from "./backfill-scope.js";
|
||||
|
||||
/** The plan of a payload that must parse; fails the test if it doesn't. */
|
||||
function planOf(payload: string) {
|
||||
const parsed = parseBackfillPayload(payload);
|
||||
if (!parsed.ok) throw new Error(`expected payload to parse, got: ${parsed.error}`);
|
||||
return parsed.plan;
|
||||
}
|
||||
|
||||
function errorOf(payload: string): string {
|
||||
const parsed = parseBackfillPayload(payload);
|
||||
if (parsed.ok) throw new Error(`expected payload to be rejected, got plan: ${parsed.plan.describe}`);
|
||||
return parsed.error;
|
||||
}
|
||||
|
||||
describe("parseBackfillPayload — refusing the unbounded sweep", () => {
|
||||
it("rejects an empty payload rather than selecting every empty package", () => {
|
||||
expect(errorOf("{}")).toMatch(/refusing an unbounded backfill/);
|
||||
expect(errorOf("")).toMatch(/refusing an unbounded backfill/);
|
||||
});
|
||||
|
||||
it("rejects a payload that only names an archiveType", () => {
|
||||
// This is the shape that used to select 4,330 packages / 5.4TB.
|
||||
expect(errorOf('{"archiveType":"ZIP"}')).toMatch(/refusing an unbounded backfill/);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON and non-object payloads", () => {
|
||||
expect(errorOf("not json")).toMatch(/not valid JSON/);
|
||||
expect(errorOf("[]")).toMatch(/must be a JSON object/);
|
||||
expect(errorOf("null")).toMatch(/must be a JSON object/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field instead of ignoring it", () => {
|
||||
// A typo'd selector must not silently degrade into "no selector".
|
||||
expect(errorOf('{"packageIDs":["abc"]}')).toMatch(/unknown field\(s\): packageIDs/);
|
||||
});
|
||||
|
||||
it("allows the broad sweep only when it asks for itself", () => {
|
||||
const plan = planOf('{"archiveType":"RAR","limit":50,"allowBroadSweep":true}');
|
||||
expect(plan.selector).toEqual({ archiveType: "RAR" });
|
||||
expect(plan.limit).toBe(50);
|
||||
});
|
||||
|
||||
it("rejects allowBroadSweep combined with a selector as a contradiction", () => {
|
||||
expect(errorOf('{"fileNameLike":"%.z01","allowBroadSweep":true}')).toMatch(/unscoped sweeps only/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBackfillPayload — packageIds", () => {
|
||||
it("accepts, trims and de-duplicates an explicit id list", () => {
|
||||
const plan = planOf('{"packageIds":[" abc123 ","abc123","def456"]}');
|
||||
expect(plan.selector.packageIds).toEqual(["abc123", "def456"]);
|
||||
});
|
||||
|
||||
it("rejects an empty list, non-strings and non-identifier ids", () => {
|
||||
expect(errorOf('{"packageIds":[]}')).toMatch(/must not be empty/);
|
||||
expect(errorOf('{"packageIds":[1,2]}')).toMatch(/only strings/);
|
||||
expect(errorOf('{"packageIds":["a\'; DROP TABLE packages--"]}')).toMatch(/not a valid identifier/);
|
||||
expect(errorOf('{"packageIds":"abc"}')).toMatch(/must be an array/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFileNameLike", () => {
|
||||
it("maps the four accepted wildcard positions to Prisma filters", () => {
|
||||
expect(parseFileNameLike("%.z01")).toEqual({ endsWith: ".z01" });
|
||||
expect(parseFileNameLike("Dragon%")).toEqual({ startsWith: "Dragon" });
|
||||
expect(parseFileNameLike("%dragon%")).toEqual({ contains: "dragon" });
|
||||
expect(parseFileNameLike("Pack.z01")).toEqual({ equals: "Pack.z01" });
|
||||
});
|
||||
|
||||
it("refuses patterns that would match far more than intended", () => {
|
||||
expect(parseFileNameLike("%")).toHaveProperty("error");
|
||||
expect(parseFileNameLike("%%")).toHaveProperty("error");
|
||||
expect(parseFileNameLike(" ")).toHaveProperty("error");
|
||||
// Too little literal text to be a meaningful scope
|
||||
expect(parseFileNameLike("%a%")).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("refuses interior and underscore wildcards rather than mis-translating them", () => {
|
||||
expect(parseFileNameLike("%.z%1")).toMatchObject({ error: expect.stringMatching(/interior wildcard/) });
|
||||
expect(parseFileNameLike("%.z0_")).toMatchObject({ error: expect.stringMatching(/_ wildcard/) });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBackfillPayload — limits and flags", () => {
|
||||
it("defaults the limit and caps it", () => {
|
||||
expect(planOf('{"fileNameLike":"%.z01"}').limit).toBe(DEFAULT_BACKFILL_LIMIT);
|
||||
expect(errorOf(`{"fileNameLike":"%.z01","limit":${MAX_BACKFILL_LIMIT + 1}}`)).toMatch(/exceeds the maximum/);
|
||||
expect(errorOf('{"fileNameLike":"%.z01","limit":0}')).toMatch(/positive integer/);
|
||||
expect(errorOf('{"fileNameLike":"%.z01","limit":1.5}')).toMatch(/positive integer/);
|
||||
});
|
||||
|
||||
it("defaults rangedOnly and recoverDestIds off, and requires booleans", () => {
|
||||
const plan = planOf('{"fileNameLike":"%.z01"}');
|
||||
expect(plan.rangedOnly).toBe(false);
|
||||
expect(plan.recoverDestIds).toBe(false);
|
||||
expect(errorOf('{"fileNameLike":"%.z01","rangedOnly":"yes"}')).toMatch(/rangedOnly must be a boolean/);
|
||||
});
|
||||
|
||||
it("parses the full spanned-ZIP repair payload", () => {
|
||||
const plan = planOf(
|
||||
'{"fileNameLike":"%.z01","archiveType":"ZIP","limit":250,"rangedOnly":true,"recoverDestIds":true}'
|
||||
);
|
||||
expect(plan.selector).toEqual({ fileName: { endsWith: ".z01" }, archiveType: "ZIP" });
|
||||
expect(plan.limit).toBe(250);
|
||||
expect(plan.rangedOnly).toBe(true);
|
||||
expect(plan.recoverDestIds).toBe(true);
|
||||
expect(plan.describe).toContain('fileName.endsWith=".z01"');
|
||||
expect(plan.describe).toContain("rangedOnly");
|
||||
});
|
||||
|
||||
it("rejects an unsupported archiveType", () => {
|
||||
expect(errorOf('{"fileNameLike":"%.z01","archiveType":"TAR"}')).toMatch(/archiveType must be one of/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Payload parsing for the `backfill_filelists` pg_notify request.
|
||||
*
|
||||
* The original payload shape was `{limit, archiveType}` — both optional, both
|
||||
* defaulted. That made the *unbounded* sweep the easiest thing to trigger:
|
||||
* `SELECT pg_notify('backfill_filelists', '{}')` selected every Package with
|
||||
* `fileCount = 0` of every archive type, oldest first, and started downloading.
|
||||
* On a real catalogue that is multiple terabytes of traffic aimed at packages
|
||||
* nobody asked to repair.
|
||||
*
|
||||
* So the rule here is: **a request must name what it wants**. A payload with no
|
||||
* selector is rejected outright, and the broad "every empty package of type X"
|
||||
* sweep has to opt in explicitly via `allowBroadSweep`. Omitting a field can
|
||||
* only ever narrow the job or fail it — never widen it.
|
||||
*/
|
||||
|
||||
export type BackfillArchiveType = "ZIP" | "RAR" | "SEVEN_Z";
|
||||
|
||||
const ARCHIVE_TYPES: BackfillArchiveType[] = ["ZIP", "RAR", "SEVEN_Z"];
|
||||
|
||||
/** Hard ceiling on `limit`, so even a deliberate broad sweep stays bounded. */
|
||||
export const MAX_BACKFILL_LIMIT = 2000;
|
||||
/** Hard ceiling on an explicit id list — keeps the SQL `IN (...)` sane. */
|
||||
export const MAX_BACKFILL_PACKAGE_IDS = 2000;
|
||||
export const DEFAULT_BACKFILL_LIMIT = 200;
|
||||
|
||||
/** Minimum literal characters in a `fileNameLike` pattern, so `%` can't stand alone. */
|
||||
const MIN_FILENAME_LITERAL = 3;
|
||||
|
||||
/** Prisma-shaped filename filter — a closed set of forms, never raw SQL. */
|
||||
export type FileNameFilter =
|
||||
| { equals: string }
|
||||
| { startsWith: string }
|
||||
| { endsWith: string }
|
||||
| { contains: string };
|
||||
|
||||
export interface BackfillSelector {
|
||||
/** Explicit package ids: the most tightly bounded selector there is. */
|
||||
packageIds?: string[];
|
||||
fileName?: FileNameFilter;
|
||||
archiveType?: BackfillArchiveType;
|
||||
}
|
||||
|
||||
export interface BackfillPlan {
|
||||
selector: BackfillSelector;
|
||||
limit: number;
|
||||
/**
|
||||
* Never fall back to a full `downloadFile` of the archive, even when the
|
||||
* cheap ranged read fails. Set this for repairs where the full-download cost
|
||||
* would be absurd (a spanned set's listing lives in ~64KB of its final
|
||||
* volume; downloading the set to reach it can be hundreds of gigabytes).
|
||||
*/
|
||||
rangedOnly: boolean;
|
||||
/**
|
||||
* Allow one scan of the destination channel to recover `destMessageIds` for
|
||||
* candidates that have none. Opt-in because the scan itself costs a few
|
||||
* hundred paginated `searchChatMessages` calls.
|
||||
*/
|
||||
recoverDestIds: boolean;
|
||||
/** Compact description of the scope, for the batch log line. */
|
||||
describe: string;
|
||||
}
|
||||
|
||||
export type ParsedBackfillPayload =
|
||||
| { ok: true; plan: BackfillPlan }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Translate a restricted LIKE pattern into a Prisma filter.
|
||||
*
|
||||
* Only leading and/or trailing `%` are accepted — an interior `%`, a `_`
|
||||
* wildcard, or a pattern with too little literal text is rejected rather than
|
||||
* quietly matching far more than the caller meant.
|
||||
*/
|
||||
export function parseFileNameLike(pattern: string): FileNameFilter | { error: string } {
|
||||
if (typeof pattern !== "string") return { error: "fileNameLike must be a string" };
|
||||
const raw = pattern.trim();
|
||||
if (raw.length === 0) return { error: "fileNameLike must not be empty" };
|
||||
|
||||
const leading = raw.startsWith("%");
|
||||
const trailing = raw.endsWith("%");
|
||||
const literal = raw.slice(leading ? 1 : 0, trailing && raw.length > 1 ? -1 : undefined);
|
||||
|
||||
if (literal.includes("%")) {
|
||||
return { error: "fileNameLike supports a leading and/or trailing % only (no interior wildcard)" };
|
||||
}
|
||||
if (literal.includes("_")) {
|
||||
return { error: "fileNameLike does not support the _ wildcard — use % or a literal name" };
|
||||
}
|
||||
if (literal.length < MIN_FILENAME_LITERAL) {
|
||||
return {
|
||||
error: `fileNameLike needs at least ${MIN_FILENAME_LITERAL} literal characters (got "${literal}")`,
|
||||
};
|
||||
}
|
||||
|
||||
if (leading && trailing) return { contains: literal };
|
||||
if (leading) return { endsWith: literal };
|
||||
if (trailing) return { startsWith: literal };
|
||||
return { equals: literal };
|
||||
}
|
||||
|
||||
function parsePackageIds(value: unknown): string[] | { error: string } {
|
||||
if (!Array.isArray(value)) return { error: "packageIds must be an array of package ids" };
|
||||
if (value.length === 0) return { error: "packageIds must not be empty" };
|
||||
if (value.length > MAX_BACKFILL_PACKAGE_IDS) {
|
||||
return { error: `packageIds holds ${value.length} ids — the maximum is ${MAX_BACKFILL_PACKAGE_IDS}` };
|
||||
}
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== "string") return { error: "packageIds must contain only strings" };
|
||||
const id = entry.trim();
|
||||
// cuid()s are alphanumeric; the character class also rules out anything
|
||||
// that could confuse a log line or a hand-written SQL check.
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) {
|
||||
return { error: `packageIds contains an id that is not a valid identifier: "${entry}"` };
|
||||
}
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function describeSelector(selector: BackfillSelector, plan: Pick<BackfillPlan, "limit" | "rangedOnly" | "recoverDestIds">): string {
|
||||
const bits: string[] = [];
|
||||
if (selector.packageIds) bits.push(`packageIds=${selector.packageIds.length}`);
|
||||
if (selector.fileName) {
|
||||
const [key, value] = Object.entries(selector.fileName)[0];
|
||||
bits.push(`fileName.${key}=${JSON.stringify(value)}`);
|
||||
}
|
||||
bits.push(`archiveType=${selector.archiveType ?? "ZIP|RAR|SEVEN_Z"}`);
|
||||
bits.push(`limit=${plan.limit}`);
|
||||
if (plan.rangedOnly) bits.push("rangedOnly");
|
||||
if (plan.recoverDestIds) bits.push("recoverDestIds");
|
||||
return bits.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a `backfill_filelists` payload.
|
||||
*
|
||||
* Accepted fields (all optional except that *some* selector is required):
|
||||
* packageIds string[] — repair exactly these packages
|
||||
* fileNameLike string — restricted LIKE: "%.z01", "Pack%", "%dragon%"
|
||||
* archiveType ZIP|RAR|SEVEN_Z
|
||||
* limit number — 1..MAX_BACKFILL_LIMIT, default DEFAULT_BACKFILL_LIMIT
|
||||
* rangedOnly boolean — refuse the full-download fallback
|
||||
* recoverDestIds boolean — allow one destination-channel scan to recover ids
|
||||
* allowBroadSweep boolean — required to run with no narrowing selector
|
||||
*/
|
||||
export function parseBackfillPayload(payloadJson: string): ParsedBackfillPayload {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payloadJson === "" ? "{}" : payloadJson);
|
||||
} catch {
|
||||
return { ok: false, error: "payload is not valid JSON" };
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, error: "payload must be a JSON object" };
|
||||
}
|
||||
const raw = parsed as Record<string, unknown>;
|
||||
|
||||
const known = new Set([
|
||||
"packageIds",
|
||||
"fileNameLike",
|
||||
"archiveType",
|
||||
"limit",
|
||||
"rangedOnly",
|
||||
"recoverDestIds",
|
||||
"allowBroadSweep",
|
||||
]);
|
||||
const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
|
||||
if (unknownKeys.length > 0) {
|
||||
// Fail rather than ignore: a typo'd `packageIDs` would otherwise silently
|
||||
// become "no selector" and — worse, if allowBroadSweep were also set —
|
||||
// a full sweep.
|
||||
return { ok: false, error: `unknown field(s): ${unknownKeys.join(", ")}` };
|
||||
}
|
||||
|
||||
let limit = DEFAULT_BACKFILL_LIMIT;
|
||||
if (raw.limit !== undefined) {
|
||||
if (typeof raw.limit !== "number" || !Number.isInteger(raw.limit) || raw.limit < 1) {
|
||||
return { ok: false, error: "limit must be a positive integer" };
|
||||
}
|
||||
if (raw.limit > MAX_BACKFILL_LIMIT) {
|
||||
return { ok: false, error: `limit ${raw.limit} exceeds the maximum of ${MAX_BACKFILL_LIMIT}` };
|
||||
}
|
||||
limit = raw.limit;
|
||||
}
|
||||
|
||||
for (const flag of ["rangedOnly", "recoverDestIds", "allowBroadSweep"] as const) {
|
||||
if (raw[flag] !== undefined && typeof raw[flag] !== "boolean") {
|
||||
return { ok: false, error: `${flag} must be a boolean` };
|
||||
}
|
||||
}
|
||||
const rangedOnly = raw.rangedOnly === true;
|
||||
const recoverDestIds = raw.recoverDestIds === true;
|
||||
const allowBroadSweep = raw.allowBroadSweep === true;
|
||||
|
||||
const selector: BackfillSelector = {};
|
||||
|
||||
if (raw.packageIds !== undefined) {
|
||||
const ids = parsePackageIds(raw.packageIds);
|
||||
if (!Array.isArray(ids)) return { ok: false, error: ids.error };
|
||||
selector.packageIds = ids;
|
||||
}
|
||||
|
||||
if (raw.fileNameLike !== undefined) {
|
||||
const filter = parseFileNameLike(raw.fileNameLike as string);
|
||||
if ("error" in filter) return { ok: false, error: filter.error };
|
||||
selector.fileName = filter;
|
||||
}
|
||||
|
||||
if (raw.archiveType !== undefined) {
|
||||
if (typeof raw.archiveType !== "string" || !ARCHIVE_TYPES.includes(raw.archiveType as BackfillArchiveType)) {
|
||||
return { ok: false, error: `archiveType must be one of ${ARCHIVE_TYPES.join(", ")}` };
|
||||
}
|
||||
selector.archiveType = raw.archiveType as BackfillArchiveType;
|
||||
}
|
||||
|
||||
const isNarrowed = selector.packageIds !== undefined || selector.fileName !== undefined;
|
||||
if (!isNarrowed && !allowBroadSweep) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"refusing an unbounded backfill: pass packageIds or fileNameLike to scope it, " +
|
||||
'or set {"allowBroadSweep":true} to deliberately sweep every empty package',
|
||||
};
|
||||
}
|
||||
if (allowBroadSweep && isNarrowed) {
|
||||
return { ok: false, error: "allowBroadSweep is for unscoped sweeps only — drop it or drop the selector" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
plan: {
|
||||
selector,
|
||||
limit,
|
||||
rangedOnly,
|
||||
recoverDestIds,
|
||||
describe: describeSelector(selector, { limit, rangedOnly, recoverDestIds }),
|
||||
},
|
||||
};
|
||||
}
|
||||
+401
-170
@@ -1,79 +1,71 @@
|
||||
import path from "path";
|
||||
import type { Client } from "tdl";
|
||||
import { mkdir, rm } from "fs/promises";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { db } from "./db/client.js";
|
||||
import { config } from "./util/config.js";
|
||||
import { childLogger } from "./util/logger.js";
|
||||
import { withTdlibMutex } from "./util/mutex.js";
|
||||
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
||||
import { downloadFile } from "./tdlib/download.js";
|
||||
import { downloadFile, invokeWithTimeout } from "./tdlib/download.js";
|
||||
import { scanChatDocuments, type ChatDocument } from "./tdlib/chat-documents.js";
|
||||
import { getActiveAccounts } from "./db/queries.js";
|
||||
import { readZipCentralDirectory } from "./archive/zip-reader.js";
|
||||
import { readRarContents } from "./archive/rar-reader.js";
|
||||
import { read7zContents } from "./archive/sevenz-reader.js";
|
||||
import { extractSlicerTags } from "./archive/slicer-tags.js";
|
||||
import { readScannedListingRanged } from "./archive/ranged/dispatch.js";
|
||||
import type { RangedPart } from "./archive/ranged/sevenz-ranged.js";
|
||||
import { planListingRead, planRangedFallback } from "./archive/listing-plan.js";
|
||||
import { buildDestIndex, resolveDestPartSet, type DestIndex } from "./dest-index.js";
|
||||
import { parseBackfillPayload, type BackfillPlan } from "./backfill-scope.js";
|
||||
import type { FileEntry } from "./archive/zip-reader.js";
|
||||
|
||||
const log = childLogger("backfill");
|
||||
|
||||
/**
|
||||
* Re-extract file listings for Packages whose fileCount is 0 — usually
|
||||
* caused by historical bugs in the archive readers (e.g. the RAR parser
|
||||
* that silently returned [] for every archive before 0bdd4ba).
|
||||
* Re-extract file listings for Packages whose fileCount is 0 — historically a
|
||||
* reader bug (the RAR parser that silently returned [] before 0bdd4ba; the
|
||||
* spanned-ZIP offset bug before 402c317) rather than a genuinely empty archive.
|
||||
*
|
||||
* For each candidate Package:
|
||||
* 1. Download all destMessageIds from the destination channel
|
||||
* 2. Run the appropriate reader (ZIP / RAR / 7Z) on the assembled files
|
||||
* 3. Insert PackageFile rows + update Package.fileCount
|
||||
* 4. Clean up the temp files
|
||||
* Two things dominate the cost of doing this, and both are handled here:
|
||||
*
|
||||
* Triggered via pg_notify "backfill_filelists" with optional payload
|
||||
* `{"limit": N, "archiveType": "RAR"}` — both fields optional, defaults
|
||||
* are limit=100, archiveType=any.
|
||||
* 1. **Reading the listing.** A file list lives in a few tens of kilobytes of
|
||||
* an archive's header or tail. `readScannedListingRanged` fetches exactly
|
||||
* those bytes, so a 35GB spanned set costs ~64KB instead of 35GB. The
|
||||
* full-download path remains as a fallback for archives ranged reading
|
||||
* genuinely cannot handle — and `rangedOnly` turns it off when the
|
||||
* difference would be terabytes.
|
||||
*
|
||||
* 2. **Knowing which messages the archive's parts are.** A Package whose
|
||||
* `destMessageIds` array is empty falls back to `[destMessageId]`, which is
|
||||
* the *first* uploaded part. A lone `.z01` has no central directory at all,
|
||||
* so that package can never be listed. With `recoverDestIds` the batch pays
|
||||
* for one destination-channel scan and recovers the complete, ordered part
|
||||
* set for every candidate at once — then persists it, so the package stays
|
||||
* repairable.
|
||||
*
|
||||
* Triggered via pg_notify "backfill_filelists"; see `backfill-scope.ts` for the
|
||||
* payload contract. A payload with no narrowing selector is rejected — the
|
||||
* unscoped sweep has to ask for itself.
|
||||
*/
|
||||
export async function processBackfillRequest(payloadJson: string): Promise<void> {
|
||||
let limit = 100;
|
||||
let archiveTypeFilter: "ZIP" | "RAR" | "SEVEN_Z" | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(payloadJson) as { limit?: number; archiveType?: string };
|
||||
if (typeof parsed.limit === "number" && parsed.limit > 0) limit = parsed.limit;
|
||||
if (parsed.archiveType === "ZIP" || parsed.archiveType === "RAR" || parsed.archiveType === "SEVEN_Z") {
|
||||
archiveTypeFilter = parsed.archiveType;
|
||||
}
|
||||
} catch {
|
||||
// Empty / invalid payload — use defaults
|
||||
const parsed = parseBackfillPayload(payloadJson);
|
||||
if (!parsed.ok) {
|
||||
log.warn({ payload: payloadJson, error: parsed.error }, "Backfill request rejected — nothing was read or written");
|
||||
return;
|
||||
}
|
||||
const plan = parsed.plan;
|
||||
|
||||
const candidates = await db.package.findMany({
|
||||
where: {
|
||||
fileCount: 0,
|
||||
destChannelId: { not: null },
|
||||
destMessageId: { not: null },
|
||||
archiveType: archiveTypeFilter
|
||||
? archiveTypeFilter
|
||||
: { in: ["ZIP", "RAR", "SEVEN_Z"] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
archiveType: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
destMessageIds: true,
|
||||
isMultipart: true,
|
||||
partCount: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const candidates = await findBackfillCandidates(plan);
|
||||
if (candidates.length === 0) {
|
||||
log.info({ archiveTypeFilter }, "Backfill: no candidates with fileCount=0");
|
||||
log.info({ scope: plan.describe }, "Backfill: no candidates with fileCount=0");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalBytes = candidates.reduce((sum, c) => sum + c.fileSize, 0n);
|
||||
log.info(
|
||||
{ count: candidates.length, archiveTypeFilter },
|
||||
{ count: candidates.length, scope: plan.describe, totalBytes: totalBytes.toString() },
|
||||
"Backfill: starting batch"
|
||||
);
|
||||
|
||||
@@ -101,33 +93,47 @@ export async function processBackfillRequest(payloadJson: string): Promise<void>
|
||||
// May already be loaded
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
const destIndexes = await buildDestIndexes(client, plan, candidates);
|
||||
|
||||
const counters: BackfillCounters = {
|
||||
processed: 0,
|
||||
listedRanged: 0,
|
||||
listedDownload: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
idsRecovered: 0,
|
||||
concatUnlistable: 0,
|
||||
};
|
||||
|
||||
for (const pkg of candidates) {
|
||||
processed++;
|
||||
counters.processed++;
|
||||
const ctx = { packageId: pkg.id, fileName: pkg.fileName };
|
||||
|
||||
try {
|
||||
await processOnePackage(client, pkg, ctx);
|
||||
succeeded++;
|
||||
await repairOnePackage(client, pkg, ctx, plan, destIndexes, counters);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
counters.failed++;
|
||||
log.warn({ err, ...ctx }, "Backfill failed for package");
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ processed, succeeded, failed, archiveTypeFilter },
|
||||
"Backfill batch complete"
|
||||
);
|
||||
log.info({ ...counters, scope: plan.describe }, "Backfill batch complete");
|
||||
} finally {
|
||||
await closeTdlibClient(client).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface BackfillCounters {
|
||||
processed: number;
|
||||
listedRanged: number;
|
||||
listedDownload: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
idsRecovered: number;
|
||||
concatUnlistable: number;
|
||||
}
|
||||
|
||||
interface BackfillPackage {
|
||||
id: string;
|
||||
fileName: string;
|
||||
@@ -140,143 +146,368 @@ interface BackfillPackage {
|
||||
partCount: number;
|
||||
}
|
||||
|
||||
async function processOnePackage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
/** Build the `where` clause from a validated plan. */
|
||||
export function backfillCandidateWhere(plan: BackfillPlan): Prisma.PackageWhereInput {
|
||||
const { selector } = plan;
|
||||
return {
|
||||
fileCount: 0,
|
||||
destChannelId: { not: null },
|
||||
destMessageId: { not: null },
|
||||
archiveType: selector.archiveType ?? { in: ["ZIP", "RAR", "SEVEN_Z"] },
|
||||
...(selector.packageIds ? { id: { in: selector.packageIds } } : {}),
|
||||
...(selector.fileName ? { fileName: selector.fileName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function findBackfillCandidates(plan: BackfillPlan): Promise<BackfillPackage[]> {
|
||||
return db.package.findMany({
|
||||
where: backfillCandidateWhere(plan),
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
archiveType: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
destMessageIds: true,
|
||||
isMultipart: true,
|
||||
partCount: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: plan.limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan each destination channel that has candidates needing id recovery, once.
|
||||
*
|
||||
* The scan is the expensive part of a repair run (a few hundred paginated
|
||||
* `searchChatMessages` calls on a large channel), so it is opt-in via
|
||||
* `recoverDestIds` and it is amortised across the whole batch. Its by-product —
|
||||
* a fileId and size for every destination document — also removes the need for
|
||||
* a per-part `getMessage` on every candidate, recovered or not.
|
||||
*/
|
||||
async function buildDestIndexes(
|
||||
client: Client,
|
||||
plan: BackfillPlan,
|
||||
candidates: BackfillPackage[]
|
||||
): Promise<Map<string, DestIndex>> {
|
||||
const indexes = new Map<string, DestIndex>();
|
||||
if (!plan.recoverDestIds) return indexes;
|
||||
|
||||
const channelIds = new Set<string>();
|
||||
for (const pkg of candidates) {
|
||||
if (pkg.destChannelId && pkg.destMessageIds.length === 0) channelIds.add(pkg.destChannelId);
|
||||
}
|
||||
if (channelIds.size === 0) {
|
||||
log.info("Backfill: every candidate already has destMessageIds — skipping the destination scan");
|
||||
return indexes;
|
||||
}
|
||||
|
||||
for (const channelId of channelIds) {
|
||||
const channel = await db.telegramChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
select: { telegramId: true, title: true },
|
||||
});
|
||||
if (!channel) {
|
||||
log.warn({ channelId }, "Backfill: destination channel not found in DB — cannot recover ids for it");
|
||||
continue;
|
||||
}
|
||||
log.info({ channelId, title: channel.title }, "Backfill: scanning destination channel to recover destMessageIds");
|
||||
const scan = await scanChatDocuments(client, channel.telegramId);
|
||||
if (scan.truncated) {
|
||||
log.warn(
|
||||
{ channelId, pages: scan.pages },
|
||||
"Backfill: destination scan hit the page limit — recovery may be incomplete for older packages"
|
||||
);
|
||||
}
|
||||
indexes.set(channelId, buildDestIndex(scan.documents));
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
/** Resolved destination parts plus how they were obtained. */
|
||||
interface ResolvedDestParts {
|
||||
parts: RangedPart[];
|
||||
/** Message ids in upload order, when a complete set was recovered from a scan. */
|
||||
recoveredIds: bigint[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a Package's destination messages into ranged parts (fileId + size + name),
|
||||
* in upload order. Prefers the scan index (free) over `getMessage` (one API call
|
||||
* per part), and recovers the full set from the anchor message when
|
||||
* `destMessageIds` is empty.
|
||||
*/
|
||||
async function resolveDestParts(
|
||||
client: Client,
|
||||
pkg: BackfillPackage,
|
||||
ctx: { packageId: string; fileName: string }
|
||||
chatTelegramId: bigint,
|
||||
index: DestIndex | undefined
|
||||
): Promise<ResolvedDestParts | { error: string }> {
|
||||
const toRangedPart = (doc: ChatDocument): RangedPart => ({
|
||||
fileId: doc.fileId,
|
||||
fileSize: doc.fileSize,
|
||||
fileName: doc.fileName,
|
||||
});
|
||||
|
||||
if (pkg.destMessageIds.length > 0) {
|
||||
const parts: RangedPart[] = [];
|
||||
for (const msgId of pkg.destMessageIds) {
|
||||
const cached = index?.byMessageId.get(msgId.toString());
|
||||
if (cached) {
|
||||
parts.push(toRangedPart(cached));
|
||||
continue;
|
||||
}
|
||||
const resolved = await fetchDocumentPart(client, chatTelegramId, msgId, pkg);
|
||||
if ("error" in resolved) return resolved;
|
||||
parts.push(resolved.part);
|
||||
}
|
||||
return { parts, recoveredIds: null };
|
||||
}
|
||||
|
||||
if (!pkg.destMessageId) return { error: "package has no destination message id" };
|
||||
|
||||
// A single-part package needs no recovery: its one message id is complete.
|
||||
if (pkg.partCount <= 1 && !pkg.isMultipart) {
|
||||
const cached = index?.byMessageId.get(pkg.destMessageId.toString());
|
||||
if (cached) return { parts: [toRangedPart(cached)], recoveredIds: null };
|
||||
const resolved = await fetchDocumentPart(client, chatTelegramId, pkg.destMessageId, pkg);
|
||||
if ("error" in resolved) return resolved;
|
||||
return { parts: [resolved.part], recoveredIds: null };
|
||||
}
|
||||
|
||||
if (!index) {
|
||||
return {
|
||||
error:
|
||||
`destMessageIds is empty and the package has ${pkg.partCount} parts; ` +
|
||||
"destMessageId alone is only the first part. Re-run with recoverDestIds to scan the destination channel",
|
||||
};
|
||||
}
|
||||
|
||||
const resolution = resolveDestPartSet(index, pkg.destMessageId, pkg.partCount);
|
||||
if (!resolution.ok) return { error: resolution.reason };
|
||||
|
||||
return {
|
||||
parts: resolution.parts.map(toRangedPart),
|
||||
recoveredIds: resolution.parts.map((p) => p.id),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDocumentPart(
|
||||
client: Client,
|
||||
chatTelegramId: bigint,
|
||||
messageId: bigint,
|
||||
pkg: BackfillPackage
|
||||
): Promise<{ part: RangedPart } | { error: string }> {
|
||||
const message = await invokeWithTimeout<{
|
||||
content?: { document?: { file_name?: string; document?: { id: number; size: number } } };
|
||||
}>(client, {
|
||||
_: "getMessage",
|
||||
chat_id: Number(chatTelegramId),
|
||||
message_id: Number(messageId),
|
||||
});
|
||||
const doc = message?.content?.document;
|
||||
if (!doc?.document?.id) {
|
||||
return { error: `destination message ${messageId} has no document` };
|
||||
}
|
||||
return {
|
||||
part: {
|
||||
fileId: String(doc.document.id),
|
||||
fileSize: BigInt(doc.document.size),
|
||||
fileName: doc.file_name ?? pkg.fileName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function repairOnePackage(
|
||||
client: Client,
|
||||
pkg: BackfillPackage,
|
||||
ctx: { packageId: string; fileName: string },
|
||||
plan: BackfillPlan,
|
||||
destIndexes: Map<string, DestIndex>,
|
||||
counters: BackfillCounters
|
||||
): Promise<void> {
|
||||
if (!pkg.destChannelId || !pkg.destMessageId) {
|
||||
log.debug(ctx, "Skipping: no destination channel/message");
|
||||
counters.skipped++;
|
||||
log.info({ ...ctx, reason: "no destination channel/message" }, "Backfill skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the destination channel's Telegram ID
|
||||
const destChannel = await db.telegramChannel.findUnique({
|
||||
where: { id: pkg.destChannelId },
|
||||
select: { telegramId: true },
|
||||
});
|
||||
if (!destChannel) {
|
||||
throw new Error("Destination channel not found in DB");
|
||||
if (!destChannel) throw new Error("Destination channel not found in DB");
|
||||
|
||||
const index = destIndexes.get(pkg.destChannelId);
|
||||
const resolved = await resolveDestParts(client, pkg, destChannel.telegramId, index);
|
||||
if ("error" in resolved) {
|
||||
counters.skipped++;
|
||||
log.info({ ...ctx, reason: resolved.error }, "Backfill skipped");
|
||||
return;
|
||||
}
|
||||
const chatId = Number(destChannel.telegramId);
|
||||
const { parts, recoveredIds } = resolved;
|
||||
|
||||
// Resolve which message IDs to download. The Package may carry a
|
||||
// single destMessageId or multiple destMessageIds (for multipart).
|
||||
const messageIds: bigint[] =
|
||||
pkg.destMessageIds.length > 0
|
||||
? pkg.destMessageIds
|
||||
: pkg.destMessageId
|
||||
? [pkg.destMessageId]
|
||||
: [];
|
||||
|
||||
if (messageIds.length === 0) {
|
||||
throw new Error("Package has no destination message IDs");
|
||||
// Persist a recovered part set before attempting the read: the ids are correct
|
||||
// regardless of whether the listing turns out to be readable, and recording
|
||||
// them is what makes the package repairable on any later attempt (and lets the
|
||||
// bot deliver every part rather than just the first).
|
||||
if (recoveredIds) {
|
||||
const written = await persistRecoveredDestIds(pkg.id, recoveredIds);
|
||||
if (written) {
|
||||
counters.idsRecovered++;
|
||||
log.info({ ...ctx, destMessageIds: recoveredIds.map(Number) }, "Recovered destination message ids");
|
||||
}
|
||||
}
|
||||
|
||||
const route = planListingRead({
|
||||
archiveType: pkg.archiveType,
|
||||
sourceFileName: pkg.fileName,
|
||||
destFileNames: parts.map((p) => p.fileName),
|
||||
totalSize: parts.reduce((sum, p) => sum + p.fileSize, 0n),
|
||||
maxDownloadBytes: BigInt(config.maxZipSizeMB) * 1024n * 1024n,
|
||||
rangedOnly: plan.rangedOnly,
|
||||
});
|
||||
|
||||
if (route.route === "skip") {
|
||||
counters.skipped++;
|
||||
if (/concat/.test(route.reason)) counters.concatUnlistable++;
|
||||
log.info(
|
||||
{ ...ctx, reason: route.reason, destFileNames: parts.map((p) => p.fileName) },
|
||||
"Backfill skipped — destination copy cannot be listed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Cheap path: read only the bytes that hold the listing ──
|
||||
log.info(
|
||||
{ ...ctx, parts: parts.length, reason: route.reason },
|
||||
"Backfill reading listing via RANGED read (no full download)"
|
||||
);
|
||||
let entries = await readScannedListingRanged(pkg.archiveType, client, parts);
|
||||
let pathTaken: "ranged" | "download" = "ranged";
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
const totalSize = parts.reduce((sum, p) => sum + p.fileSize, 0n);
|
||||
const fallback = planRangedFallback({
|
||||
totalSize,
|
||||
maxDownloadBytes: BigInt(config.maxZipSizeMB) * 1024n * 1024n,
|
||||
rangedOnly: plan.rangedOnly,
|
||||
});
|
||||
if (fallback.route === "skip") {
|
||||
counters.skipped++;
|
||||
log.info({ ...ctx, reason: fallback.reason }, "Backfill skipped after ranged read returned nothing");
|
||||
return;
|
||||
}
|
||||
log.warn(
|
||||
{ ...ctx, reason: fallback.reason, bytes: totalSize.toString() },
|
||||
"Backfill falling back to FULL DOWNLOAD"
|
||||
);
|
||||
entries = await downloadAndRead(client, pkg, parts);
|
||||
pathTaken = "download";
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
counters.skipped++;
|
||||
log.warn({ ...ctx, pathTaken }, "Reader returned 0 entries — archive may be encrypted or corrupt");
|
||||
return;
|
||||
}
|
||||
|
||||
await writeListing(pkg, entries, ctx);
|
||||
if (pathTaken === "ranged") counters.listedRanged++;
|
||||
else counters.listedDownload++;
|
||||
log.info({ ...ctx, fileCount: entries.length, pathTaken }, "Backfilled file list");
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `destMessageIds` for a package that had none. Guarded on the array still
|
||||
* being empty so a value written concurrently — by another worker, or by hand —
|
||||
* is never clobbered. Returns whether a row was actually updated.
|
||||
*/
|
||||
async function persistRecoveredDestIds(packageId: string, ids: bigint[]): Promise<boolean> {
|
||||
const result = await db.package.updateMany({
|
||||
where: { id: packageId, destMessageIds: { isEmpty: true } },
|
||||
data: { destMessageIds: ids },
|
||||
});
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
/** The original full-download path, kept as the fallback for un-ranged archives. */
|
||||
async function downloadAndRead(
|
||||
client: Client,
|
||||
pkg: BackfillPackage,
|
||||
parts: RangedPart[]
|
||||
): Promise<FileEntry[]> {
|
||||
const tempDir = path.join(config.tempDir, `backfill_${pkg.id}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const partPaths: string[] = [];
|
||||
|
||||
for (let i = 0; i < messageIds.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const message = (await client.invoke({
|
||||
_: "getMessage",
|
||||
chat_id: chatId,
|
||||
message_id: Number(messageIds[i]),
|
||||
})) as unknown as {
|
||||
content?: { document?: { file_name?: string; document?: { id: number; size: number } } };
|
||||
};
|
||||
|
||||
const doc = message?.content?.document;
|
||||
if (!doc?.document?.id) {
|
||||
throw new Error(`Destination message ${messageIds[i]} has no document`);
|
||||
}
|
||||
|
||||
const fileId = String(doc.document.id);
|
||||
const fileName = doc.file_name ?? `${pkg.id}.part${i + 1}`;
|
||||
const localPath = path.join(tempDir, fileName);
|
||||
|
||||
await downloadFile(
|
||||
client,
|
||||
fileId,
|
||||
localPath,
|
||||
BigInt(doc.document.size),
|
||||
fileName
|
||||
);
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const localPath = path.join(tempDir, part.fileName || `${pkg.id}.part${i + 1}`);
|
||||
await downloadFile(client, part.fileId, localPath, part.fileSize, part.fileName);
|
||||
partPaths.push(localPath);
|
||||
}
|
||||
|
||||
// Run the appropriate reader on the assembled file(s)
|
||||
let entries: FileEntry[] = [];
|
||||
if (pkg.archiveType === "ZIP") {
|
||||
entries = await readZipCentralDirectory(partPaths);
|
||||
} else if (pkg.archiveType === "RAR") {
|
||||
// unrar auto-discovers sibling parts when in the same directory
|
||||
entries = await readRarContents(partPaths[0]);
|
||||
} else if (pkg.archiveType === "SEVEN_Z") {
|
||||
entries = await read7zContents(partPaths[0]);
|
||||
} else {
|
||||
log.debug({ ...ctx, archiveType: pkg.archiveType }, "Skipping unsupported archive type");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
log.warn(ctx, "Reader returned 0 entries — archive may be encrypted or corrupt");
|
||||
return;
|
||||
}
|
||||
|
||||
// Also derive slicer tags from the file list so the backfilled packages
|
||||
// gain the same search/filter context as newly-ingested ones.
|
||||
const slicerTags = extractSlicerTags(entries);
|
||||
|
||||
// Write everything in a single transaction so a partial backfill never
|
||||
// leaves the Package half-indexed.
|
||||
await db.$transaction(async (tx) => {
|
||||
// Re-check fileCount inside the transaction: another worker might
|
||||
// have backfilled this package between our read and write.
|
||||
const current = await tx.package.findUnique({
|
||||
where: { id: pkg.id },
|
||||
select: { fileCount: true, tags: true },
|
||||
});
|
||||
if (current && current.fileCount > 0) {
|
||||
log.debug({ ...ctx, existingFileCount: current.fileCount }, "Already backfilled by another worker — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.packageFile.deleteMany({ where: { packageId: pkg.id } });
|
||||
await tx.packageFile.createMany({
|
||||
data: entries.map((e) => ({
|
||||
packageId: pkg.id,
|
||||
path: e.path,
|
||||
fileName: e.fileName,
|
||||
extension: e.extension,
|
||||
compressedSize: e.compressedSize,
|
||||
uncompressedSize: e.uncompressedSize,
|
||||
crc32: e.crc32,
|
||||
})),
|
||||
});
|
||||
|
||||
// Merge slicer tags with whatever's already on the Package (preserve
|
||||
// channel category, manual tags, etc.).
|
||||
const existingTags = current?.tags ?? [];
|
||||
const mergedTags = [...new Set([...existingTags, ...slicerTags])];
|
||||
|
||||
await tx.package.update({
|
||||
where: { id: pkg.id },
|
||||
data: { fileCount: entries.length, tags: mergedTags },
|
||||
});
|
||||
});
|
||||
|
||||
log.info({ ...ctx, fileCount: entries.length }, "Backfilled file list");
|
||||
if (pkg.archiveType === "ZIP") return readZipCentralDirectory(partPaths);
|
||||
// unrar / 7z auto-discover sibling parts when in the same directory
|
||||
if (pkg.archiveType === "RAR") return readRarContents(partPaths[0]);
|
||||
if (pkg.archiveType === "SEVEN_Z") return read7zContents(partPaths[0]);
|
||||
return [];
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function writeListing(
|
||||
pkg: BackfillPackage,
|
||||
entries: FileEntry[],
|
||||
ctx: { packageId: string; fileName: string }
|
||||
): Promise<void> {
|
||||
// Also derive slicer tags from the file list so the backfilled packages
|
||||
// gain the same search/filter context as newly-ingested ones.
|
||||
const slicerTags = extractSlicerTags(entries);
|
||||
|
||||
// Write everything in a single transaction so a partial backfill never
|
||||
// leaves the Package half-indexed.
|
||||
await db.$transaction(async (tx) => {
|
||||
// Re-check fileCount inside the transaction: another worker might
|
||||
// have backfilled this package between our read and write.
|
||||
const current = await tx.package.findUnique({
|
||||
where: { id: pkg.id },
|
||||
select: { fileCount: true, tags: true },
|
||||
});
|
||||
if (current && current.fileCount > 0) {
|
||||
log.debug({ ...ctx, existingFileCount: current.fileCount }, "Already backfilled by another worker — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.packageFile.deleteMany({ where: { packageId: pkg.id } });
|
||||
await tx.packageFile.createMany({
|
||||
data: entries.map((e) => ({
|
||||
packageId: pkg.id,
|
||||
path: e.path,
|
||||
fileName: e.fileName,
|
||||
extension: e.extension,
|
||||
compressedSize: e.compressedSize,
|
||||
uncompressedSize: e.uncompressedSize,
|
||||
crc32: e.crc32,
|
||||
})),
|
||||
});
|
||||
|
||||
// Merge slicer tags with whatever's already on the Package (preserve
|
||||
// channel category, manual tags, etc.).
|
||||
const existingTags = current?.tags ?? [];
|
||||
const mergedTags = [...new Set([...existingTags, ...slicerTags])];
|
||||
|
||||
await tx.package.update({
|
||||
where: { id: pkg.id },
|
||||
data: { fileCount: entries.length, tags: mergedTags },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap pure-DB backfill: walk Packages that already have PackageFile rows
|
||||
* but no slicer tags, recompute the tags from their extensions, and merge
|
||||
|
||||
+57
-22
@@ -377,6 +377,7 @@ export interface ActivityUpdate {
|
||||
zipsDuplicate?: number;
|
||||
zipsIngested?: number;
|
||||
zipsBackfilled?: number;
|
||||
zipsForwarded?: number;
|
||||
}
|
||||
|
||||
export async function updateRunActivity(
|
||||
@@ -401,6 +402,7 @@ export async function updateRunActivity(
|
||||
...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }),
|
||||
...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }),
|
||||
...(activity.zipsBackfilled !== undefined && { zipsBackfilled: activity.zipsBackfilled }),
|
||||
...(activity.zipsForwarded !== undefined && { zipsForwarded: activity.zipsForwarded }),
|
||||
...(activity.currentTopicId !== undefined && { currentTopicId: activity.currentTopicId }),
|
||||
...(activity.currentAccountChannelMapId !== undefined && {
|
||||
currentAccountChannelMapId: activity.currentAccountChannelMapId,
|
||||
@@ -432,6 +434,7 @@ export async function completeIngestionRun(
|
||||
zipsDuplicate: number;
|
||||
zipsIngested: number;
|
||||
zipsBackfilled: number;
|
||||
zipsForwarded: number;
|
||||
}
|
||||
) {
|
||||
return db.ingestionRun.update({
|
||||
@@ -639,6 +642,13 @@ export async function setChannelForum(channelId: string, isForum: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function setChannelAllowsForwarding(channelId: string, allowsForwarding: boolean) {
|
||||
return db.telegramChannel.update({
|
||||
where: { id: channelId },
|
||||
data: { allowsForwarding },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTopicProgress(mappingId: string) {
|
||||
return db.topicProgress.findMany({
|
||||
where: { accountChannelMapId: mappingId },
|
||||
@@ -1023,6 +1033,30 @@ export interface PlaceholderCandidate {
|
||||
destChannel: { telegramId: bigint } | null;
|
||||
}
|
||||
|
||||
type PlaceholderRow = {
|
||||
id: string; archiveType: string; fileName: string; fileCount: number; fileSize: bigint;
|
||||
destMessageId: bigint | null; destMessageIds: bigint[]; destChannelId: string | null;
|
||||
};
|
||||
|
||||
async function enrichWithDestChannel(rows: PlaceholderRow[]): Promise<PlaceholderCandidate[]> {
|
||||
if (rows.length === 0) return [];
|
||||
const destChannelIds = [...new Set(rows.map((r) => r.destChannelId).filter((id): id is string => !!id))];
|
||||
const channels = destChannelIds.length
|
||||
? await db.telegramChannel.findMany({
|
||||
where: { id: { in: destChannelIds } },
|
||||
select: { id: true, telegramId: true },
|
||||
})
|
||||
: [];
|
||||
const telegramIdById = new Map(channels.map((c) => [c.id, c.telegramId]));
|
||||
return rows.map((row) => ({
|
||||
id: row.id, archiveType: row.archiveType, fileName: row.fileName, fileCount: row.fileCount, fileSize: row.fileSize,
|
||||
destMessageId: row.destMessageId, destMessageIds: row.destMessageIds,
|
||||
destChannel: row.destChannelId && telegramIdById.has(row.destChannelId)
|
||||
? { telegramId: telegramIdById.get(row.destChannelId)! }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every placeholder Package matching name+size (oldest first). Package
|
||||
* has no direct `destChannel` relation (only the scalar `destChannelId`), so
|
||||
@@ -1052,29 +1086,30 @@ export async function findPlaceholderCandidates(
|
||||
},
|
||||
orderBy: { indexedAt: "asc" },
|
||||
});
|
||||
if (rows.length === 0) return [];
|
||||
return enrichWithDestChannel(rows);
|
||||
}
|
||||
|
||||
const destChannelIds = [...new Set(rows.map((r) => r.destChannelId).filter((id): id is string => !!id))];
|
||||
const channels = destChannelIds.length
|
||||
? await db.telegramChannel.findMany({
|
||||
where: { id: { in: destChannelIds } },
|
||||
select: { id: true, telegramId: true },
|
||||
})
|
||||
: [];
|
||||
const telegramIdById = new Map(channels.map((c) => [c.id, c.telegramId]));
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
archiveType: row.archiveType,
|
||||
fileName: row.fileName,
|
||||
fileCount: row.fileCount,
|
||||
fileSize: row.fileSize,
|
||||
destMessageId: row.destMessageId,
|
||||
destMessageIds: row.destMessageIds,
|
||||
destChannel: row.destChannelId && telegramIdById.has(row.destChannelId)
|
||||
? { telegramId: telegramIdById.get(row.destChannelId)! }
|
||||
: null,
|
||||
}));
|
||||
/**
|
||||
* Find every uploaded Package (any provenance, any channel) matching
|
||||
* name+size, for the forward-priority path's cross-channel CRC-fingerprint
|
||||
* dedup check. Unlike findPlaceholderCandidates, this is NOT restricted to
|
||||
* placeholder rows — it exists to catch the case where the exact same
|
||||
* archive was independently uploaded (not reposted/forwarded) to two
|
||||
* different source channels.
|
||||
*/
|
||||
export async function findFingerprintDedupCandidates(
|
||||
fileName: string,
|
||||
fileSize: bigint,
|
||||
): Promise<PlaceholderCandidate[]> {
|
||||
const rows = await db.package.findMany({
|
||||
where: { fileName, fileSize, destMessageId: { not: null } },
|
||||
select: {
|
||||
id: true, archiveType: true, fileName: true, fileCount: true, fileSize: true,
|
||||
destMessageId: true, destMessageIds: true, destChannelId: true,
|
||||
},
|
||||
orderBy: { indexedAt: "asc" },
|
||||
});
|
||||
return enrichWithDestChannel(rows);
|
||||
}
|
||||
|
||||
export async function findPlaceholderCandidate(
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildDestIndex, resolveDestPartSet } from "./dest-index.js";
|
||||
import type { ChatDocument } from "./tdlib/chat-documents.js";
|
||||
|
||||
let nextId = 100;
|
||||
function doc(fileName: string, opts: { id?: number; size?: number } = {}): ChatDocument {
|
||||
const id = opts.id ?? nextId++;
|
||||
return {
|
||||
id: BigInt(id),
|
||||
fileName,
|
||||
fileId: `f${id}`,
|
||||
fileSize: BigInt(opts.size ?? 1024),
|
||||
date: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDestIndex + resolveDestPartSet — spanned ZIP recovery", () => {
|
||||
it("recovers the complete ordered volume set from the .z01 message alone", () => {
|
||||
const z01 = doc("Pack.z01", { id: 10 });
|
||||
const z02 = doc("Pack.z02", { id: 11 });
|
||||
const zip = doc("Pack.zip", { id: 12 });
|
||||
const index = buildDestIndex([z01, z02, zip, doc("Unrelated.zip", { id: 13 })]);
|
||||
|
||||
const resolved = resolveDestPartSet(index, 10n, 3);
|
||||
expect(resolved.ok).toBe(true);
|
||||
if (!resolved.ok) return;
|
||||
// .z01, .z02, then the bare .zip as the FINAL volume — the order the
|
||||
// EOCD-bearing tail read depends on.
|
||||
expect(resolved.parts.map((p) => p.fileName)).toEqual(["Pack.z01", "Pack.z02", "Pack.zip"]);
|
||||
expect(resolved.parts.map((p) => Number(p.id))).toEqual([10, 11, 12]);
|
||||
expect(resolved.kind).toBe("archive-set");
|
||||
});
|
||||
|
||||
it("carries fileId and size through, so no getMessage call is needed per part", () => {
|
||||
const index = buildDestIndex([
|
||||
doc("Pack.z01", { id: 10, size: 500 }),
|
||||
doc("Pack.zip", { id: 11, size: 700 }),
|
||||
]);
|
||||
const resolved = resolveDestPartSet(index, 10n, 2);
|
||||
expect(resolved.ok).toBe(true);
|
||||
if (!resolved.ok) return;
|
||||
expect(resolved.parts.map((p) => p.fileId)).toEqual(["f10", "f11"]);
|
||||
expect(resolved.parts.map((p) => Number(p.fileSize))).toEqual([500, 700]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDestPartSet — refusing to guess", () => {
|
||||
it("reports a missing anchor message instead of inventing a set", () => {
|
||||
const index = buildDestIndex([doc("Pack.z01", { id: 10 }), doc("Pack.zip", { id: 11 })]);
|
||||
const resolved = resolveDestPartSet(index, 999n, 2);
|
||||
expect(resolved).toMatchObject({ ok: false });
|
||||
if (resolved.ok) return;
|
||||
expect(resolved.reason).toMatch(/was not found in the channel scan/);
|
||||
});
|
||||
|
||||
it("refuses a set whose part count disagrees with the package", () => {
|
||||
// Two uploads sharing a base name get merged by groupArchiveSets into one
|
||||
// oversized set — writing that back would mix two archives together.
|
||||
const index = buildDestIndex([
|
||||
doc("Pack.z01", { id: 10 }),
|
||||
doc("Pack.z02", { id: 11 }),
|
||||
doc("Pack.zip", { id: 12 }),
|
||||
]);
|
||||
const resolved = resolveDestPartSet(index, 10n, 2);
|
||||
expect(resolved).toMatchObject({ ok: false });
|
||||
if (resolved.ok) return;
|
||||
expect(resolved.reason).toMatch(/refusing to write an incomplete or merged set/);
|
||||
});
|
||||
|
||||
it("resolves a genuine single-part package from its one message", () => {
|
||||
const index = buildDestIndex([doc("Solo.zip", { id: 20 })]);
|
||||
const resolved = resolveDestPartSet(index, 20n, 1);
|
||||
expect(resolved).toMatchObject({ ok: true });
|
||||
if (!resolved.ok) return;
|
||||
expect(resolved.parts.map((p) => p.fileName)).toEqual(["Solo.zip"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDestIndex — .concat.NNN repacks", () => {
|
||||
it("groups repack chunks that no archive pattern matches", () => {
|
||||
// These names are invisible to archive/detect.ts, so without their own
|
||||
// grouping a repacked package looks identical to one whose messages are gone.
|
||||
const index = buildDestIndex([
|
||||
doc("Pack.concat.003", { id: 32 }),
|
||||
doc("Pack.concat.001", { id: 30 }),
|
||||
doc("Pack.concat.002", { id: 31 }),
|
||||
]);
|
||||
const resolved = resolveDestPartSet(index, 30n, 3);
|
||||
expect(resolved.ok).toBe(true);
|
||||
if (!resolved.ok) return;
|
||||
expect(resolved.kind).toBe("concat-repack");
|
||||
expect(resolved.parts.map((p) => p.fileName)).toEqual([
|
||||
"Pack.concat.001",
|
||||
"Pack.concat.002",
|
||||
"Pack.concat.003",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps two different repacks apart", () => {
|
||||
const index = buildDestIndex([
|
||||
doc("A.concat.001", { id: 40 }),
|
||||
doc("A.concat.002", { id: 41 }),
|
||||
doc("B.concat.001", { id: 50 }),
|
||||
doc("B.concat.002", { id: 51 }),
|
||||
]);
|
||||
const a = resolveDestPartSet(index, 40n, 2);
|
||||
expect(a).toMatchObject({ ok: true });
|
||||
if (!a.ok) return;
|
||||
expect(a.parts.map((p) => Number(p.id))).toEqual([40, 41]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { isArchiveAttachment } from "./archive/detect.js";
|
||||
import { groupArchiveSets } from "./archive/multipart.js";
|
||||
import { isConcatRepackName, concatRepackBase, concatChunkIndex } from "./archive/listing-plan.js";
|
||||
import type { ChatDocument } from "./tdlib/chat-documents.js";
|
||||
|
||||
/**
|
||||
* An index over one destination-channel scan, used to recover the destination
|
||||
* message ids of packages whose `destMessageIds` array was never populated.
|
||||
*
|
||||
* Two kinds of part set live in here:
|
||||
*
|
||||
* - `archive-set` — grouped by `groupArchiveSets`, i.e. the same grouping the
|
||||
* ingestion path uses, so a `.z01 … .zip` spanned set or a
|
||||
* `.zip.001 …` byte split comes back in upload order.
|
||||
* - `concat-repack` — `<base>.concat.NNN` chunks, which match no archive
|
||||
* pattern at all and so are invisible to `groupArchiveSets`.
|
||||
* They still need grouping: a package repacked this way has
|
||||
* real destination messages worth recording even though its
|
||||
* listing can never be read back.
|
||||
*/
|
||||
export type DestPartSetKind = "archive-set" | "concat-repack";
|
||||
|
||||
export interface DestPartSet {
|
||||
kind: DestPartSetKind;
|
||||
parts: ChatDocument[];
|
||||
}
|
||||
|
||||
export interface DestIndex {
|
||||
byMessageId: Map<string, ChatDocument>;
|
||||
setByMessageId: Map<string, DestPartSet>;
|
||||
documentCount: number;
|
||||
}
|
||||
|
||||
export function buildDestIndex(documents: ChatDocument[]): DestIndex {
|
||||
const byMessageId = new Map<string, ChatDocument>();
|
||||
for (const doc of documents) byMessageId.set(doc.id.toString(), doc);
|
||||
|
||||
const setByMessageId = new Map<string, DestPartSet>();
|
||||
|
||||
// Recognized archive names: reuse the ingestion grouping verbatim so the part
|
||||
// order here matches the order the parts were uploaded in.
|
||||
const archives = documents.filter((d) => isArchiveAttachment(d.fileName));
|
||||
for (const set of groupArchiveSets(archives)) {
|
||||
if (set.parts.length === 0) continue;
|
||||
const entry: DestPartSet = { kind: "archive-set", parts: set.parts };
|
||||
for (const part of set.parts) setByMessageId.set(part.id.toString(), entry);
|
||||
}
|
||||
|
||||
// `<base>.concat.NNN` repack chunks, grouped by base and ordered by chunk number.
|
||||
const concatGroups = new Map<string, ChatDocument[]>();
|
||||
for (const doc of documents) {
|
||||
if (!isConcatRepackName(doc.fileName)) continue;
|
||||
const key = concatRepackBase(doc.fileName);
|
||||
const group = concatGroups.get(key) ?? [];
|
||||
group.push(doc);
|
||||
concatGroups.set(key, group);
|
||||
}
|
||||
for (const group of concatGroups.values()) {
|
||||
group.sort((a, b) => concatChunkIndex(a.fileName) - concatChunkIndex(b.fileName));
|
||||
const entry: DestPartSet = { kind: "concat-repack", parts: group };
|
||||
for (const part of group) setByMessageId.set(part.id.toString(), entry);
|
||||
}
|
||||
|
||||
return { byMessageId, setByMessageId, documentCount: documents.length };
|
||||
}
|
||||
|
||||
export type DestResolution =
|
||||
| { ok: true; kind: DestPartSetKind; parts: ChatDocument[] }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Resolve a package's full destination part set from one known message id.
|
||||
*
|
||||
* Refuses anything it cannot corroborate. In particular the recovered set must
|
||||
* hold exactly `expectedPartCount` parts: the destination channel can legitimately
|
||||
* contain two uploads sharing a base name (a re-post, a duplicate ingestion), and
|
||||
* `groupArchiveSets` merges those into one oversized set. Writing that merged set
|
||||
* back to `destMessageIds` would hand the bot a mix of two archives, so a count
|
||||
* mismatch is reported and the row is left alone.
|
||||
*/
|
||||
export function resolveDestPartSet(
|
||||
index: DestIndex,
|
||||
anchorMessageId: bigint,
|
||||
expectedPartCount: number
|
||||
): DestResolution {
|
||||
const key = anchorMessageId.toString();
|
||||
const anchor = index.byMessageId.get(key);
|
||||
if (!anchor) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `destination message ${key} was not found in the channel scan (deleted, or outside the scanned range)`,
|
||||
};
|
||||
}
|
||||
|
||||
const set = index.setByMessageId.get(key);
|
||||
if (!set) {
|
||||
if (expectedPartCount === 1) {
|
||||
return { ok: true, kind: "archive-set", parts: [anchor] };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: `destination message ${key} ("${anchor.fileName}") matched no part set, but the package expects ${expectedPartCount} parts`,
|
||||
};
|
||||
}
|
||||
|
||||
if (set.parts.length !== expectedPartCount) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`resolved ${set.parts.length} destination part(s) for "${anchor.fileName}" but the package records ` +
|
||||
`${expectedPartCount} — refusing to write an incomplete or merged set`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, kind: set.kind, parts: set.parts };
|
||||
}
|
||||
@@ -538,12 +538,30 @@ function handleManualUpload(uploadId: string): void {
|
||||
|
||||
// ── Backfill file-list handler ──
|
||||
//
|
||||
// Trigger via:
|
||||
// SELECT pg_notify('backfill_filelists', '{"limit":50,"archiveType":"RAR"}');
|
||||
// A request must say what it wants: a payload with no narrowing selector is
|
||||
// rejected outright rather than sweeping every empty package in the catalogue.
|
||||
// See `backfill-scope.ts` for the full contract.
|
||||
//
|
||||
// Both fields are optional. archiveType filters to one of ZIP/RAR/SEVEN_Z.
|
||||
// Default limit is 100. The handler queues so multiple notifications run
|
||||
// sequentially (no concurrent TDLib downloads competing for the mutex).
|
||||
// -- repair the ZIP-spec spanned sets (.z01 … .zip) that the pre-402c317
|
||||
// -- reader bug left with no file list. Ranged reads only (~64KB each, vs
|
||||
// -- ~944GB of full downloads), and one destination scan to recover the
|
||||
// -- destMessageIds that were never recorded.
|
||||
// SELECT pg_notify('backfill_filelists', '{
|
||||
// "fileNameLike": "%.z01",
|
||||
// "archiveType": "ZIP",
|
||||
// "limit": 250,
|
||||
// "rangedOnly": true,
|
||||
// "recoverDestIds": true
|
||||
// }');
|
||||
//
|
||||
// -- repair a specific handful
|
||||
// SELECT pg_notify('backfill_filelists', '{"packageIds":["ckxyz…","ckabc…"],"rangedOnly":true}');
|
||||
//
|
||||
// -- the old broad sweep, now explicit about being one
|
||||
// SELECT pg_notify('backfill_filelists', '{"archiveType":"RAR","limit":50,"allowBroadSweep":true}');
|
||||
//
|
||||
// The handler queues so multiple notifications run sequentially (no concurrent
|
||||
// TDLib downloads competing for the mutex).
|
||||
function handleBackfillFilelists(payload: string): void {
|
||||
fetchQueue = fetchQueue
|
||||
.then(() => processBackfillRequest(payload))
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { db } from "./db/client.js";
|
||||
import { childLogger } from "./util/logger.js";
|
||||
import { downloadFileRange } from "./tdlib/range-download.js";
|
||||
import { invokeWithTimeout } from "./tdlib/download.js";
|
||||
import { parseZipCentralDirectoryFromTail, MIN_ZIP_TAIL_BYTES } from "./archive/central-directory.js";
|
||||
import { fingerprintsMatch, crcFingerprint } from "./archive/fingerprint.js";
|
||||
import {
|
||||
findPlaceholderCandidates,
|
||||
@@ -15,6 +13,7 @@ import { readSevenZListingRanged, type RangedPart } from "./archive/ranged/seven
|
||||
import { readRarListingRanged } from "./archive/ranged/rar-ranged.js";
|
||||
import { tdlibRangeReader } from "./archive/ranged/range-reader.js";
|
||||
import { fullDownloadListing } from "./archive/ranged/fallback.js";
|
||||
import { readScannedZipListing, readScannedListingRanged } from "./archive/ranged/dispatch.js";
|
||||
import type { Client } from "tdl";
|
||||
|
||||
const log = childLogger("provenance-backfill");
|
||||
@@ -36,38 +35,6 @@ export interface BackfillArgs {
|
||||
previewMsgId?: bigint | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a ZIP central directory from the tail of a (possibly multipart)
|
||||
* archive. `parts` is ordered; only the LAST part carries the EOCD record.
|
||||
* `fileSize` on each part is that part's own size (NOT the whole-archive
|
||||
* total) so the download offset stays within that part's bounds, while
|
||||
* `tailStart` passed to the parser is the logical whole-archive offset
|
||||
* (preceding parts' sizes + the offset within the last part).
|
||||
*/
|
||||
async function readScannedZipListing(
|
||||
client: Client,
|
||||
parts: { fileId: string; fileSize: bigint }[],
|
||||
): Promise<FileEntry[] | null> {
|
||||
if (parts.length === 0) return null;
|
||||
const lastPart = parts[parts.length - 1];
|
||||
const precedingSize = parts.slice(0, -1).reduce((sum, p) => sum + Number(p.fileSize), 0);
|
||||
const lastSize = Number(lastPart.fileSize);
|
||||
for (const tailBytes of [MIN_ZIP_TAIL_BYTES, MIN_ZIP_TAIL_BYTES * 4]) {
|
||||
const partOffset = Math.max(0, lastSize - tailBytes);
|
||||
const downloadLen = Math.min(tailBytes, lastSize);
|
||||
try {
|
||||
const buf = await downloadFileRange(client, lastPart.fileId, partOffset, downloadLen, lastPart.fileSize);
|
||||
const tailStart = precedingSize + partOffset;
|
||||
return parseZipCentralDirectoryFromTail(buf, tailStart);
|
||||
} catch (err) {
|
||||
if (err instanceof RangeError) continue; // try a larger tail
|
||||
log.warn({ err, fileId: lastPart.fileId }, "ranged ZIP listing failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the destination copy's message(s) into ranged parts (file id +
|
||||
* size + name), in order, so a multipart destination copy is reconstructed
|
||||
@@ -106,25 +73,13 @@ async function resolveDestParts(
|
||||
}
|
||||
}
|
||||
|
||||
async function readScannedListingRanged(
|
||||
archiveType: string,
|
||||
client: Client,
|
||||
parts: RangedPart[],
|
||||
): Promise<FileEntry[] | null> {
|
||||
const read = tdlibRangeReader(client);
|
||||
if (archiveType === "ZIP") return readScannedZipListing(client, parts);
|
||||
if (archiveType === "SEVEN_Z") return readSevenZListingRanged(parts, read);
|
||||
if (archiveType === "RAR") return readRarListingRanged(parts, read);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CRC fingerprint entries for a placeholder candidate: start from
|
||||
* its stored PackageFile CRCs, and if those are incomplete (e.g. a rebuild
|
||||
* candidate with fileCount === 0), fall back to a fresh ranged read of the
|
||||
* candidate's own copy in the destination channel (Task 9).
|
||||
*/
|
||||
async function resolveCandidateFingerprintEntries(
|
||||
export async function resolveCandidateFingerprintEntries(
|
||||
client: Client,
|
||||
candidate: PlaceholderCandidate,
|
||||
): Promise<FileEntry[]> {
|
||||
@@ -145,7 +100,7 @@ async function resolveCandidateFingerprintEntries(
|
||||
if (destParts) {
|
||||
const read = tdlibRangeReader(client);
|
||||
destEntries =
|
||||
candidate.archiveType === "ZIP" ? await readScannedZipListing(client, destParts)
|
||||
candidate.archiveType === "ZIP" ? await readScannedZipListing(destParts, read)
|
||||
: candidate.archiveType === "SEVEN_Z" ? await readSevenZListingRanged(destParts, read)
|
||||
: candidate.archiveType === "RAR" ? await readRarListingRanged(destParts, read)
|
||||
: null;
|
||||
@@ -164,7 +119,7 @@ async function resolveCandidateFingerprintEntries(
|
||||
* refute a match — callers must fall back to name+size confidence rather
|
||||
* than treating this as a mismatch.
|
||||
*/
|
||||
function compareFingerprints(a: FileEntry[], b: FileEntry[]): "match" | "mismatch" | "incomplete" {
|
||||
export function compareFingerprints(a: FileEntry[], b: FileEntry[]): "match" | "mismatch" | "incomplete" {
|
||||
const fa = crcFingerprint(a);
|
||||
const fb = crcFingerprint(b);
|
||||
if (!fa.complete || !fb.complete) return "incomplete";
|
||||
|
||||
+18
-104
@@ -1,8 +1,7 @@
|
||||
import type { Client } from "tdl";
|
||||
import { config } from "./util/config.js";
|
||||
import { childLogger } from "./util/logger.js";
|
||||
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
||||
import { invokeWithTimeout, MAX_SCAN_PAGES } from "./tdlib/download.js";
|
||||
import { scanChatDocuments } from "./tdlib/chat-documents.js";
|
||||
import { isArchiveAttachment } from "./archive/detect.js";
|
||||
import { extractCreatorFromFileName } from "./archive/creator.js";
|
||||
import { groupArchiveSets } from "./archive/multipart.js";
|
||||
@@ -263,119 +262,38 @@ export async function rebuildPackageDatabase(
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the destination channel for document messages using searchChatMessages.
|
||||
* Returns archive messages in chronological order (oldest first).
|
||||
* Scan the destination channel and keep only the documents whose names
|
||||
* `archive/detect.ts` recognizes. The paging itself lives in
|
||||
* `tdlib/chat-documents.ts` and is shared with the file-list repair path.
|
||||
*/
|
||||
async function scanDestinationChannel(
|
||||
client: Client,
|
||||
chatId: bigint,
|
||||
onProgress?: (messagesScanned: number) => Promise<void>
|
||||
): Promise<TelegramMessage[]> {
|
||||
const scan = await scanChatDocuments(client, chatId, onProgress);
|
||||
|
||||
const archives: TelegramMessage[] = [];
|
||||
let currentFromId = 0;
|
||||
let totalScanned = 0;
|
||||
let pageCount = 0;
|
||||
let lastProgressUpdate = 0;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (pageCount >= MAX_SCAN_PAGES) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), pageCount, totalScanned },
|
||||
"Hit max page limit for destination scan, stopping"
|
||||
for (const doc of scan.documents) {
|
||||
if (isArchiveAttachment(doc.fileName)) {
|
||||
archives.push(doc);
|
||||
} else {
|
||||
// Not matched by any pattern in archive/detect.ts, so it is dropped without a
|
||||
// packages/skipped_packages row. Grep "unrecognized attachment" to find naming
|
||||
// schemes we do not handle yet.
|
||||
log.debug(
|
||||
{ chatId: chatId.toString(), messageId: Number(doc.id), fileName: doc.fileName },
|
||||
"Skipping unrecognized attachment (no archive/document pattern matched)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
pageCount++;
|
||||
|
||||
const previousFromId = currentFromId;
|
||||
|
||||
const result = await invokeWithTimeout<{
|
||||
messages?: {
|
||||
id: number;
|
||||
date: number;
|
||||
content: {
|
||||
_: string;
|
||||
document?: {
|
||||
file_name?: string;
|
||||
document?: {
|
||||
id: number;
|
||||
size: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}[];
|
||||
}>(client, {
|
||||
_: "searchChatMessages",
|
||||
chat_id: Number(chatId),
|
||||
// No topic context for a flat destination scan. TDLib 1.8.64+ replaced
|
||||
// `message_thread_id` / `saved_messages_topic_id` with a single
|
||||
// optional `topic_id`; for a flat scan we just omit it.
|
||||
query: "",
|
||||
from_message_id: currentFromId,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
filter: { _: "searchMessagesFilterDocument" },
|
||||
sender_id: null,
|
||||
});
|
||||
|
||||
if (!result.messages || result.messages.length === 0) break;
|
||||
|
||||
totalScanned += result.messages.length;
|
||||
|
||||
for (const msg of result.messages) {
|
||||
const doc = msg.content?.document;
|
||||
if (doc?.file_name && doc.document && isArchiveAttachment(doc.file_name)) {
|
||||
archives.push({
|
||||
id: BigInt(msg.id),
|
||||
fileName: doc.file_name,
|
||||
fileId: String(doc.document.id),
|
||||
fileSize: BigInt(doc.document.size),
|
||||
date: new Date(msg.date * 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle progress updates to every 2 seconds
|
||||
const now = Date.now();
|
||||
if (onProgress && now - lastProgressUpdate >= 2000) {
|
||||
lastProgressUpdate = now;
|
||||
await onProgress(totalScanned);
|
||||
}
|
||||
|
||||
currentFromId = result.messages[result.messages.length - 1].id;
|
||||
|
||||
// Stuck detection
|
||||
if (currentFromId === previousFromId) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), currentFromId, totalScanned },
|
||||
"Pagination stuck, breaking"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.messages.length < 100) break;
|
||||
|
||||
await sleep(config.apiDelayMs);
|
||||
}
|
||||
|
||||
// Final progress update
|
||||
if (onProgress) {
|
||||
await onProgress(totalScanned);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{
|
||||
chatId: chatId.toString(),
|
||||
archives: archives.length,
|
||||
totalScanned,
|
||||
pages: pageCount,
|
||||
},
|
||||
{ chatId: chatId.toString(), archives: archives.length, totalScanned: scan.totalScanned, pages: scan.pages },
|
||||
"Destination channel scan complete"
|
||||
);
|
||||
|
||||
// Reverse to chronological order (oldest first)
|
||||
return archives.reverse();
|
||||
return archives;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -406,7 +324,3 @@ async function updateRebuildProgress(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
+36
-15
@@ -84,25 +84,36 @@ export async function recoverIncompleteUploads(): Promise<void> {
|
||||
// Batch size for getMessages. TDLib accepts up to ~100 IDs per call.
|
||||
// Using 100 means 20k packages → ~200 round-trips instead of 20k.
|
||||
const BATCH_SIZE = 100;
|
||||
// Telegram soft-throttles sustained sequential history reads from user
|
||||
// accounts — no FLOOD_WAIT, just growing per-call latency the longer a
|
||||
// single in-flight request stream runs. A small number of batches in
|
||||
// flight at once cuts wall-clock time substantially without approaching
|
||||
// real per-account rate limits.
|
||||
const CONCURRENCY = 3;
|
||||
|
||||
const tdlibClient = client;
|
||||
const destChannelInfo = destChannel;
|
||||
const batches: (typeof packages)[] = [];
|
||||
for (const [, channelPackages] of byChannel) {
|
||||
// Group packages by destChannelId (already done) — within each group,
|
||||
// process in batches via getMessages (plural).
|
||||
for (let i = 0; i < channelPackages.length; i += BATCH_SIZE) {
|
||||
const batch = channelPackages.slice(i, i + BATCH_SIZE);
|
||||
const batchResults = await verifyMessagesBatch(
|
||||
client,
|
||||
destChannel.telegramId,
|
||||
batch.map((p) => p.destMessageId!)
|
||||
);
|
||||
batches.push(channelPackages.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const pkg = batch[j];
|
||||
const result = batchResults[j];
|
||||
async function processBatch(batch: typeof packages) {
|
||||
const batchResults = await verifyMessagesBatch(
|
||||
tdlibClient,
|
||||
destChannelInfo.telegramId,
|
||||
batch.map((p) => p.destMessageId!)
|
||||
);
|
||||
|
||||
if (result.state === "exists") {
|
||||
verifiedCount++;
|
||||
} else if (result.state === "deleted") {
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const pkg = batch[j];
|
||||
const result = batchResults[j];
|
||||
|
||||
if (result.state === "exists") {
|
||||
verifiedCount++;
|
||||
} else if (result.state === "deleted") {
|
||||
log.warn(
|
||||
{
|
||||
packageId: pkg.id,
|
||||
@@ -141,10 +152,20 @@ export async function recoverIncompleteUploads(): Promise<void> {
|
||||
"Could not verify destination message — will retry on next startup"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nextBatchIndex = 0;
|
||||
async function poolWorker() {
|
||||
while (nextBatchIndex < batches.length) {
|
||||
const batch = batches[nextBatchIndex++];
|
||||
await processBatch(batch);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(CONCURRENCY, batches.length) }, () => poolWorker())
|
||||
);
|
||||
|
||||
log.info(
|
||||
{
|
||||
verifiedCount,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Client } from "tdl";
|
||||
import { config } from "../util/config.js";
|
||||
import { childLogger } from "../util/logger.js";
|
||||
import { invokeWithTimeout, MAX_SCAN_PAGES } from "./download.js";
|
||||
|
||||
const log = childLogger("chat-documents");
|
||||
|
||||
/** One document message in a chat, with everything a ranged read needs. */
|
||||
export interface ChatDocument {
|
||||
id: bigint;
|
||||
fileName: string;
|
||||
fileId: string;
|
||||
fileSize: bigint;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export interface ChatDocumentScan {
|
||||
/** Every document message with a file name, oldest first. */
|
||||
documents: ChatDocument[];
|
||||
/** Total messages returned by the search, including ones without a document. */
|
||||
totalScanned: number;
|
||||
pages: number;
|
||||
/** True when the scan stopped on MAX_SCAN_PAGES rather than running out. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page through every document message in a chat.
|
||||
*
|
||||
* `searchChatMessages` rather than `getChatHistory` because the destination
|
||||
* channel may be a hidden-history supergroup, where history reads come back
|
||||
* empty.
|
||||
*
|
||||
* Deliberately returns **all** documents and leaves filtering to the caller.
|
||||
* The rebuild path wants only names `archive/detect.ts` recognizes; the repair
|
||||
* path specifically needs the ones it does *not* — a `<base>.concat.NNN` chunk
|
||||
* matches no archive pattern, and if the scan dropped those the repair could
|
||||
* not tell "this package was repacked into an unlistable concatenation" apart
|
||||
* from "its destination messages are gone".
|
||||
*/
|
||||
export async function scanChatDocuments(
|
||||
client: Client,
|
||||
chatId: bigint,
|
||||
onProgress?: (messagesScanned: number) => Promise<void> | void
|
||||
): Promise<ChatDocumentScan> {
|
||||
const documents: ChatDocument[] = [];
|
||||
let currentFromId = 0;
|
||||
let totalScanned = 0;
|
||||
let pageCount = 0;
|
||||
let lastProgressUpdate = 0;
|
||||
let truncated = false;
|
||||
|
||||
for (;;) {
|
||||
if (pageCount >= MAX_SCAN_PAGES) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), pageCount, totalScanned },
|
||||
"Hit max page limit for chat document scan, stopping"
|
||||
);
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
pageCount++;
|
||||
|
||||
const previousFromId = currentFromId;
|
||||
|
||||
const result = await invokeWithTimeout<{
|
||||
messages?: {
|
||||
id: number;
|
||||
date: number;
|
||||
content: {
|
||||
_: string;
|
||||
document?: {
|
||||
file_name?: string;
|
||||
document?: { id: number; size: number };
|
||||
};
|
||||
};
|
||||
}[];
|
||||
}>(client, {
|
||||
_: "searchChatMessages",
|
||||
chat_id: Number(chatId),
|
||||
// No topic context for a flat scan. TDLib 1.8.64+ replaced
|
||||
// `message_thread_id` / `saved_messages_topic_id` with a single optional
|
||||
// `topic_id`; for a flat scan we just omit it.
|
||||
query: "",
|
||||
from_message_id: currentFromId,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
filter: { _: "searchMessagesFilterDocument" },
|
||||
sender_id: null,
|
||||
});
|
||||
|
||||
if (!result.messages || result.messages.length === 0) break;
|
||||
|
||||
totalScanned += result.messages.length;
|
||||
|
||||
for (const msg of result.messages) {
|
||||
const doc = msg.content?.document;
|
||||
if (doc?.file_name && doc.document) {
|
||||
documents.push({
|
||||
id: BigInt(msg.id),
|
||||
fileName: doc.file_name,
|
||||
fileId: String(doc.document.id),
|
||||
fileSize: BigInt(doc.document.size),
|
||||
date: new Date(msg.date * 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle progress updates to every 2 seconds
|
||||
const now = Date.now();
|
||||
if (onProgress && now - lastProgressUpdate >= 2000) {
|
||||
lastProgressUpdate = now;
|
||||
await onProgress(totalScanned);
|
||||
}
|
||||
|
||||
currentFromId = result.messages[result.messages.length - 1].id;
|
||||
|
||||
// Stuck detection
|
||||
if (currentFromId === previousFromId) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), currentFromId, totalScanned },
|
||||
"Pagination stuck, breaking"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.messages.length < 100) break;
|
||||
|
||||
await sleep(config.apiDelayMs);
|
||||
}
|
||||
|
||||
if (onProgress) await onProgress(totalScanned);
|
||||
|
||||
log.info(
|
||||
{ chatId: chatId.toString(), documents: documents.length, totalScanned, pages: pageCount, truncated },
|
||||
"Chat document scan complete"
|
||||
);
|
||||
|
||||
// Reverse to chronological order (oldest first)
|
||||
documents.reverse();
|
||||
return { documents, totalScanned, pages: pageCount, truncated };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -142,3 +142,34 @@ export async function closeTdlibClient(client: Client): Promise<void> {
|
||||
log.warn({ err }, "Error closing TDLib client");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune TDLib's local file cache (filesDirectory). TDLib keeps a permanent
|
||||
* copy of every file it has ever downloaded or uploaded — via inputFileLocal
|
||||
* uploads in particular — with no automatic cleanup. That cache is redundant
|
||||
* (the content already lives in the source and destination Telegram chats)
|
||||
* and grows unbounded, so it's cleared after every ingestion run. A short
|
||||
* immunity_delay protects files from an in-flight operation that might still
|
||||
* reference them.
|
||||
*/
|
||||
export async function optimizeTdlibStorage(
|
||||
client: Client,
|
||||
accountId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = (await client.invoke({
|
||||
_: "optimizeStorage",
|
||||
size: 0,
|
||||
ttl: 0,
|
||||
count: 0,
|
||||
immunity_delay: 300,
|
||||
return_deleted_file_statistics: true,
|
||||
})) as { size?: number; count?: number };
|
||||
log.info(
|
||||
{ accountId, freedBytes: result.size, freedCount: result.count },
|
||||
"TDLib local file cache pruned"
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn({ err, accountId }, "TDLib storage optimization failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,15 @@ export async function getChannelMessages(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (doc?.file_name) {
|
||||
// Not matched by any pattern in archive/detect.ts, so it is dropped without a
|
||||
// packages/skipped_packages row. Grep "unrecognized attachment" to find naming
|
||||
// schemes we do not handle yet.
|
||||
log.debug(
|
||||
{ chatId: chatId.toString(), messageId: msg.id, fileName: doc.file_name },
|
||||
"Skipping unrecognized attachment (no archive/document pattern matched)"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for photo messages (potential previews)
|
||||
const photo = msg.content?.photo;
|
||||
|
||||
@@ -280,6 +280,15 @@ export async function getTopicMessages(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (doc?.file_name) {
|
||||
// Not matched by any pattern in archive/detect.ts, so it is dropped without a
|
||||
// packages/skipped_packages row. Grep "unrecognized attachment" to find naming
|
||||
// schemes we do not handle yet.
|
||||
log.debug(
|
||||
{ chatId: chatId.toString(), topicId: topicId.toString(), messageId: msg.id, fileName: doc.file_name },
|
||||
"Skipping unrecognized attachment (no archive/document pattern matched)"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for photo messages (potential previews)
|
||||
const photo = msg.content?.photo;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { forwardArchiveToChannel } from "./forward.js";
|
||||
|
||||
function fakeClient(response: unknown) {
|
||||
return { invoke: vi.fn(async () => response) } as never;
|
||||
}
|
||||
|
||||
describe("forwardArchiveToChannel", () => {
|
||||
it("sorts message ids ascending and sends them via forwardMessages", async () => {
|
||||
const invoke = vi.fn(async (req: { message_ids: number[] }) => ({
|
||||
messages: req.message_ids.map((id) => ({ id: id + 1000 })),
|
||||
}));
|
||||
const client = { invoke } as never;
|
||||
|
||||
const result = await forwardArchiveToChannel(client, 111n, 222n, [30n, 10n, 20n]);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
_: "forwardMessages",
|
||||
chat_id: 222,
|
||||
from_chat_id: 111,
|
||||
message_ids: [10, 20, 30],
|
||||
send_copy: false,
|
||||
}),
|
||||
);
|
||||
expect(result.messageId).toBe(1010n);
|
||||
expect(result.messageIds).toEqual([1010n, 1020n, 1030n]);
|
||||
});
|
||||
|
||||
it("throws when Telegram returns null for a message (can't be forwarded)", async () => {
|
||||
const client = fakeClient({ messages: [{ id: 1001 }, null] });
|
||||
await expect(forwardArchiveToChannel(client, 111n, 222n, [10n, 20n])).rejects.toThrow(/could not forward/);
|
||||
});
|
||||
|
||||
it("throws when the response has the wrong number of messages", async () => {
|
||||
const client = fakeClient({ messages: [{ id: 1001 }] });
|
||||
await expect(forwardArchiveToChannel(client, 111n, 222n, [10n, 20n])).rejects.toThrow(/expected 2/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Client } from "tdl";
|
||||
import { childLogger } from "../util/logger.js";
|
||||
import { withFloodWait } from "../util/retry.js";
|
||||
|
||||
const log = childLogger("forward");
|
||||
|
||||
export interface ForwardResult {
|
||||
messageId: bigint;
|
||||
messageIds: bigint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward all parts of an archive set from the source chat directly to the
|
||||
* destination chat via TDLib's forwardMessages — no download, no re-upload.
|
||||
* Only usable when the source channel allows forwarding
|
||||
* (TelegramChannel.allowsForwarding); the caller is responsible for that
|
||||
* check. message_ids must be in strictly increasing order per the TDLib API,
|
||||
* so this always sorts them regardless of the order they're passed in.
|
||||
*/
|
||||
export async function forwardArchiveToChannel(
|
||||
client: Client,
|
||||
fromChatId: bigint,
|
||||
toChatId: bigint,
|
||||
sourceMessageIds: bigint[],
|
||||
): Promise<ForwardResult> {
|
||||
const sortedIds = [...sourceMessageIds].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
const numericIds = sortedIds.map((id) => Number(id));
|
||||
|
||||
log.info(
|
||||
{ fromChatId: Number(fromChatId), toChatId: Number(toChatId), count: numericIds.length },
|
||||
"Forwarding archive to destination channel"
|
||||
);
|
||||
|
||||
const result = (await withFloodWait(
|
||||
() =>
|
||||
client.invoke({
|
||||
_: "forwardMessages",
|
||||
chat_id: Number(toChatId),
|
||||
topic_id: null,
|
||||
from_chat_id: Number(fromChatId),
|
||||
message_ids: numericIds,
|
||||
options: null,
|
||||
send_copy: false,
|
||||
remove_caption: false,
|
||||
} as never),
|
||||
"forwardMessages"
|
||||
)) as { messages: ({ id: number } | null)[] };
|
||||
|
||||
const forwarded = result.messages;
|
||||
if (!forwarded || forwarded.length !== numericIds.length) {
|
||||
throw new Error(
|
||||
`forwardMessages returned ${forwarded?.length ?? 0} messages, expected ${numericIds.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const messageIds: bigint[] = [];
|
||||
for (let i = 0; i < forwarded.length; i++) {
|
||||
const msg = forwarded[i];
|
||||
if (!msg) {
|
||||
throw new Error(
|
||||
`forwardMessages could not forward source message ${sortedIds[i]} (Telegram returned null — message may not be forwardable)`
|
||||
);
|
||||
}
|
||||
messageIds.push(BigInt(msg.id));
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ fromChatId: Number(fromChatId), toChatId: Number(toChatId), messageIds: messageIds.map(Number) },
|
||||
"Forward confirmed by Telegram"
|
||||
);
|
||||
|
||||
return { messageId: messageIds[0], messageIds };
|
||||
}
|
||||
+230
-4
@@ -16,6 +16,7 @@ import {
|
||||
updateLastProcessedMessage,
|
||||
updateRunActivity,
|
||||
setChannelForum,
|
||||
setChannelAllowsForwarding,
|
||||
getTopicProgress,
|
||||
upsertTopicProgress,
|
||||
upsertChannel,
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
isTopicFetchEnabled,
|
||||
} from "./db/queries.js";
|
||||
import type { ActivityUpdate } from "./db/queries.js";
|
||||
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
||||
import { createTdlibClient, closeTdlibClient, optimizeTdlibStorage } from "./tdlib/client.js";
|
||||
import {
|
||||
getAccountChats,
|
||||
joinChatByInviteLink,
|
||||
@@ -63,6 +64,10 @@ import { hashParts } from "./archive/hash.js";
|
||||
import { readZipCentralDirectory } from "./archive/zip-reader.js";
|
||||
import { readRarContents } from "./archive/rar-reader.js";
|
||||
import { read7zContents } from "./archive/sevenz-reader.js";
|
||||
import { readScannedListingRanged } from "./archive/ranged/dispatch.js";
|
||||
import { deriveForwardContentHash } from "./archive/forward-identity.js";
|
||||
import { checkFingerprintRepost } from "./archive/forward-repost-check.js";
|
||||
import { forwardArchiveToChannel } from "./upload/forward.js";
|
||||
import { tryProvenanceBackfill } from "./provenance-backfill.js";
|
||||
import { byteLevelSplit, concatenateFiles } from "./archive/split.js";
|
||||
import { uploadToChannel, UploadStallError } from "./upload/channel.js";
|
||||
@@ -316,6 +321,7 @@ interface PipelineContext {
|
||||
zipsDuplicate: number;
|
||||
zipsIngested: number;
|
||||
zipsBackfilled: number;
|
||||
zipsForwarded: number;
|
||||
};
|
||||
/** Creator from forum topic name (null for non-forum). */
|
||||
topicCreator: string | null;
|
||||
@@ -425,6 +431,7 @@ export async function runWorkerForAccount(
|
||||
zipsDuplicate: 0,
|
||||
zipsIngested: 0,
|
||||
zipsBackfilled: 0,
|
||||
zipsForwarded: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -493,9 +500,13 @@ export async function runWorkerForAccount(
|
||||
try {
|
||||
// ── Ensure TDLib knows about this chat ──
|
||||
// getChats may not have loaded all channels (pagination, archive folder, etc.)
|
||||
// so we explicitly load each channel before scanning.
|
||||
// so we explicitly load each channel before scanning. The response is
|
||||
// also where we read has_protected_content (below) to decide whether
|
||||
// this channel is eligible for the forward-priority ingestion path.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let chatInfo: any;
|
||||
try {
|
||||
await client.invoke({
|
||||
chatInfo = await client.invoke({
|
||||
_: "getChat",
|
||||
chat_id: Number(channel.telegramId),
|
||||
});
|
||||
@@ -517,6 +528,28 @@ export async function runWorkerForAccount(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Check if channel allows forwarding ──
|
||||
// TDLib's chat.has_protected_content is documented on the general
|
||||
// Chat object (core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1chat.html),
|
||||
// but PENDING LIVE VERIFICATION here: confirm on first deploy that a
|
||||
// real chatTypeSupergroup/channel response actually populates this
|
||||
// field (some TDLib doc pages describe it in the context of basic
|
||||
// groups only). If it's ever `undefined` in practice, this block is a
|
||||
// no-op and allowsForwarding stays at its last-known/null value —
|
||||
// which safely keeps the channel on the download path.
|
||||
const hasProtectedContent: boolean | undefined = chatInfo?.has_protected_content;
|
||||
if (typeof hasProtectedContent === "boolean") {
|
||||
const allowsForwarding = !hasProtectedContent;
|
||||
if (allowsForwarding !== channel.allowsForwarding) {
|
||||
await setChannelAllowsForwarding(channel.id, allowsForwarding);
|
||||
accountLog.info(
|
||||
{ channelId: channel.id, title: channel.title, allowsForwarding },
|
||||
"Updated channel forwarding permission"
|
||||
);
|
||||
}
|
||||
channel.allowsForwarding = allowsForwarding;
|
||||
}
|
||||
|
||||
const pipelineCtx: PipelineContext = {
|
||||
client,
|
||||
runId: activeRunId,
|
||||
@@ -1170,6 +1203,7 @@ export async function runWorkerForAccount(
|
||||
accountLog.info({ counters }, "Ingestion run completed");
|
||||
} finally {
|
||||
await throttled.flush();
|
||||
await optimizeTdlibStorage(client, account.id);
|
||||
await closeTdlibClient(client);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1190,7 +1224,7 @@ export async function runWorkerForAccount(
|
||||
*/
|
||||
function inferSkipReason(errMsg: string): "DOWNLOAD_FAILED" | "UPLOAD_FAILED" | "EXTRACT_FAILED" {
|
||||
const lower = errMsg.toLowerCase();
|
||||
if (lower.includes("upload") || lower.includes("too many requests") || lower.includes("retry after") || lower.includes("send")) {
|
||||
if (lower.includes("upload") || lower.includes("forward") || lower.includes("too many requests") || lower.includes("retry after") || lower.includes("send")) {
|
||||
return "UPLOAD_FAILED";
|
||||
}
|
||||
if (lower.includes("extract") || lower.includes("metadata") || lower.includes("central directory") || lower.includes("archive")) {
|
||||
@@ -1731,6 +1765,31 @@ async function processOneArchiveSet(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Forward-priority path ──
|
||||
// If the source channel allows forwarding, try to index + forward without a
|
||||
// local download. Any failure (ranged listing miss, blocked/failed forward)
|
||||
// falls through into the existing download pipeline below so indexing
|
||||
// completeness never regresses.
|
||||
if (channel.allowsForwarding === true) {
|
||||
try {
|
||||
const forwardResult = await tryForwardArchiveSet(
|
||||
ctx, archiveSet, setIdx, totalSets, previewMatches, ingestionRunId
|
||||
);
|
||||
if (forwardResult !== undefined) {
|
||||
return forwardResult;
|
||||
}
|
||||
accountLog.info(
|
||||
{ fileName: archiveName },
|
||||
"Forward path unavailable for this archive — falling back to download+reupload"
|
||||
);
|
||||
} catch (forwardPathErr) {
|
||||
accountLog.warn(
|
||||
{ err: forwardPathErr, fileName: archiveName },
|
||||
"Forward path threw unexpectedly — falling back to download+reupload"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tempPaths: string[] = [];
|
||||
let splitPaths: string[] = [];
|
||||
|
||||
@@ -2297,6 +2356,173 @@ async function processOneArchiveSet(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt the forward-priority path for one archive set: ranged listing (no
|
||||
* download) + native Telegram forward to the destination channel.
|
||||
*
|
||||
* Returns `undefined` when the forward path isn't usable for this specific
|
||||
* archive (ranged listing failed, or the forward itself failed) — the caller
|
||||
* falls through to the existing download+reupload pipeline in that case, so
|
||||
* indexing completeness never regresses.
|
||||
*
|
||||
* Returns `null` when the archive is a confirmed duplicate (skip, same
|
||||
* contract as the pre-download dedup checks earlier in the caller).
|
||||
*
|
||||
* Returns the new Package id on success.
|
||||
*/
|
||||
async function tryForwardArchiveSet(
|
||||
ctx: PipelineContext,
|
||||
archiveSet: ArchiveSet,
|
||||
setIdx: number,
|
||||
totalSets: number,
|
||||
previewMatches: Map<string, { id: bigint; fileId: string }>,
|
||||
ingestionRunId: string,
|
||||
): Promise<string | null | undefined> {
|
||||
const {
|
||||
client, channelTitle, channel,
|
||||
destChannelTelegramId, destChannelId,
|
||||
counters, topicCreator, sourceTopicId, accountLog,
|
||||
} = ctx;
|
||||
void setIdx;
|
||||
void totalSets;
|
||||
|
||||
const archiveName = archiveSet.parts[0].fileName;
|
||||
const archType = archiveSet.type === "7Z" ? ("SEVEN_Z" as const) : archiveSet.type;
|
||||
|
||||
const scannedParts = archiveSet.parts.map((p) => ({
|
||||
fileId: p.fileId,
|
||||
fileSize: p.fileSize,
|
||||
fileName: p.fileName,
|
||||
}));
|
||||
|
||||
// Only ZIP/RAR/7z have a ranged-listing reader. For anything else (a
|
||||
// standalone DOCUMENT/STL/3MF attachment), or when the ranged listing
|
||||
// fails for a type that does have one, forward anyway with an empty
|
||||
// entries list instead of falling back to download+reupload —
|
||||
// deriveForwardContentHash and the repost/dedup checks all degrade
|
||||
// gracefully to remote.unique_id-based identity when entries are
|
||||
// empty/incomplete (see forward-identity.ts), and the entire point of a
|
||||
// forwarding-enabled channel is to avoid the download+reupload cost
|
||||
// regardless of whether inner contents can be indexed.
|
||||
const entries =
|
||||
archType === "ZIP" || archType === "RAR" || archType === "SEVEN_Z"
|
||||
? (await readScannedListingRanged(archType, client, scannedParts)) ?? []
|
||||
: [];
|
||||
|
||||
const totalArchiveSize = archiveSet.parts.reduce((sum, p) => sum + p.fileSize, 0n);
|
||||
const firstRemoteUniqueId = archiveSet.parts[0].remoteUniqueId ?? null;
|
||||
const contentHash = deriveForwardContentHash(
|
||||
entries,
|
||||
firstRemoteUniqueId,
|
||||
channel.id,
|
||||
archiveSet.parts[0].id,
|
||||
);
|
||||
|
||||
if (await packageExistsByHash(contentHash)) {
|
||||
counters.zipsDuplicate++;
|
||||
accountLog.debug({ fileName: archiveName, contentHash }, "Forward-path duplicate (hash), skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
const repost = await checkFingerprintRepost(client, entries, archiveName, totalArchiveSize);
|
||||
if (repost.isDuplicate) {
|
||||
counters.zipsDuplicate++;
|
||||
accountLog.info(
|
||||
{ fileName: archiveName, matchedPackageId: repost.matchedPackageId },
|
||||
"Forward-path duplicate (CRC fingerprint match against another channel's copy), skipping"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashLockAcquired = await tryAcquireHashLock(contentHash);
|
||||
if (!hashLockAcquired) {
|
||||
counters.zipsDuplicate++;
|
||||
accountLog.info(
|
||||
{ fileName: archiveName, contentHash },
|
||||
"Hash lock held by another worker — skipping concurrent duplicate"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await packageExistsByHash(contentHash)) {
|
||||
counters.zipsDuplicate++;
|
||||
return null;
|
||||
}
|
||||
|
||||
let destResult: { messageId: bigint; messageIds: bigint[] };
|
||||
try {
|
||||
destResult = await forwardArchiveToChannel(
|
||||
client,
|
||||
channel.telegramId,
|
||||
destChannelTelegramId,
|
||||
archiveSet.parts.map((p) => p.id),
|
||||
);
|
||||
} catch (forwardErr) {
|
||||
accountLog.warn(
|
||||
{ err: forwardErr, fileName: archiveName },
|
||||
"Forward failed — falling back to download+reupload for this archive"
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await deleteOrphanedPackageByHash(contentHash);
|
||||
|
||||
const creator =
|
||||
topicCreator ??
|
||||
extractCreatorFromFileName(archiveName) ??
|
||||
extractCreatorFromChannelTitle(channelTitle) ??
|
||||
null;
|
||||
|
||||
const tags: string[] = [];
|
||||
if (channel.category) tags.push(channel.category);
|
||||
for (const tag of extractSlicerTags(entries)) {
|
||||
if (!tags.includes(tag)) tags.push(tag);
|
||||
}
|
||||
|
||||
const stub = await createPackageStub({
|
||||
contentHash,
|
||||
fileName: archiveName,
|
||||
fileSize: totalArchiveSize,
|
||||
archiveType: archType,
|
||||
sourceChannelId: channel.id,
|
||||
sourceMessageId: archiveSet.parts[0].id,
|
||||
sourceTopicId,
|
||||
remoteUniqueId: firstRemoteUniqueId,
|
||||
destChannelId,
|
||||
destMessageId: destResult.messageId,
|
||||
destMessageIds: destResult.messageIds,
|
||||
isMultipart: archiveSet.parts.length > 1,
|
||||
partCount: archiveSet.parts.length,
|
||||
ingestionRunId,
|
||||
creator,
|
||||
tags,
|
||||
});
|
||||
|
||||
counters.zipsForwarded++;
|
||||
await deleteSkippedPackage(channel.id, archiveSet.parts[0].id);
|
||||
|
||||
let previewData: Buffer | null = null;
|
||||
let previewMsgId: bigint | null = null;
|
||||
const matchedPhoto = previewMatches.get(archiveSet.baseName);
|
||||
if (matchedPhoto) {
|
||||
previewData = await downloadPhotoThumbnail(client, matchedPhoto.fileId);
|
||||
if (previewData) previewMsgId = matchedPhoto.id;
|
||||
}
|
||||
|
||||
await updatePackageWithMetadata(stub.id, { files: entries, previewData, previewMsgId });
|
||||
|
||||
accountLog.info(
|
||||
{ fileName: archiveName, contentHash, fileCount: entries.length, creator },
|
||||
"Archive forwarded (no download)"
|
||||
);
|
||||
|
||||
return stub.id;
|
||||
} finally {
|
||||
await releaseHashLock(contentHash);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFiles(paths: string[]): Promise<void> {
|
||||
for (const p of paths) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user