Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.8 dadf03212c fix(worker): clamp RAR header re-read to 8MB to bound corrupt-archive reads
Add MAX_RAR_HEADER_BYTES constant to prevent unbounded ranged reads when
a RAR block's HeaderSize is bogus. Real RAR block headers are far smaller;
this guards against amplification attacks on corrupt/desynced archives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 12:34:00 +02:00
admin 267c72bbe8 feat(worker): enable RAR ranged listing + format-aware destination reads 2026-07-27 12:06:46 +02:00
admin 497c4876a6 test(worker): lock in RAR multipart per-volume walk 2026-07-27 12:03:14 +02:00
adminandClaude Opus 4.8 f5d913eb18 feat(worker): RAR ranged header-walk + single-part listing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 11:58:33 +02:00
adminandClaude Opus 4.8 1e11dd3fd8 fix(worker): read7zNumber throws on first-byte buffer overrun
Prevent silent masking of short reads by validating buffer bounds before
accessing the first byte. Continuation-byte overflow was already caught,
but a short read that leaves pos at/past buffer.length would return {0, pos+1}
instead of throwing, masking the error from callers' try/catch handlers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 11:55:59 +02:00
admin 086f58f9dd fix(worker): fetch packed header region for encoded-header 7z ranged listing 2026-07-27 11:50:46 +02:00
adminandClaude Opus 4.8 3595f6f097 docs: amend 7z ranged design for encoded (LZMA) headers
Live spike showed start+end sparse reconstruction is insufficient for
encoded-header 7z; fetch the mid-file packed header region as a 3rd
region (parse PackInfo). Adds Task 4b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 11:49:19 +02:00
adminandClaude Opus 4.8 abdfa437d9 feat(worker): RAR vint/signature/block-extent parsers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 11:39:19 +02:00
admin 49f14bcb0d feat(worker): dispatch 7z ranged listing + size-capped full-download fallback 2026-07-27 11:29:08 +02:00
adminandClaude Opus 4.8 d4a1cfec99 feat(worker): ranged 7z listing orchestrator + RangeReader
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 11:22:08 +02:00
admin ecedc0fec4 feat(worker): 7z signature-header parser 2026-07-27 11:17:58 +02:00
admin 1b1f5b7972 feat(worker): sparse-file reconstruction helper for ranged archive listing 2026-07-27 11:14:17 +02:00
adminandClaude Opus 4.8 e822ea3e76 docs: implementation plan for ranged RAR/7z inner-file listing
8 TDD tasks: sparse reconstruction, 7z end-header read, RAR block-walk
(incl. multipart), size-capped full-download fallback, format-aware
dispatch. 7z ships first behind the fallback as the productive spike.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 07:16:20 +02:00
adminandClaude Opus 4.8 9d16156161 docs: design for ranged inner-file listing (RAR & 7z)
Cheap listing without full download for RAR/7z placeholders: harvest header
regions via ranged reads, sparse-reconstruct, list with native 7z/unrar CLIs.
Full-download fallback (size-capped) for stragglers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 06:41:34 +02:00
adminandClaude Opus 4.8 2e7e6cca9b Backup: switch NAS transport to SMB/CIFS, fix crond/OOM/live-tar defects
Wire the backup service against the Synology share over SMB/CIFS (the NAS
authenticates with a user/password; NFS is IP-allowlist only). Also fixes
three defects found bringing the service up live:

- entrypoint crash-loop: dcron's crond fails "setpgid: Operation not
  permitted" in this runtime -> use busybox crond; make repo-init idempotent
  (check via `restic cat config`, tolerate init-on-existing) so a transient
  CIFS/lock hiccup can't kill PID 1.
- OOM: pg_dump of a ~276MB DB + tar + restic exceeded the 256M cap -> 1G.
- live tar abort: GNU tar exits 1 when TDLib files change mid-read (worker is
  live); per design this is best-effort, so tolerate exit 1, fatal only >=2.

Kuma push is now optional (empty URL disables alerting) since it's deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:36:19 +02:00
adminandClaude Opus 4.8 ceae4f384b Fix provenance-backfill multipart offsets, incomplete-fingerprint fallback, and add name-size audit trail
Multipart ZIP fingerprint reads now use per-part sizes instead of the
whole-archive total, so the tail download offset stays within the last
part's bounds on both the scanned side and the destination-copy side
(scannedFileId replaced with an ordered scannedParts list). A fingerprint
comparison is now only treated as a real mismatch when both sides have
complete CRCs and differ; incomplete comparisons (e.g. empty files) fall
back to name+size confidence instead of silently refusing to backfill.
Name+size-confidence backfills now also create an INFO
INTEGRITY_AUDIT systemNotification for later review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:46:45 +02:00
adminandClaude Opus 4.8 7595543386 feat(worker): notify on ambiguous provenance candidates instead of guessing
When multiple placeholder packages share the same name+size, try to
disambiguate via ZIP fingerprint; if that can't uniquely resolve a
single match, emit a SystemNotification and skip the backfill rather
than attributing provenance to the wrong package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:28:34 +02:00
adminandClaude Opus 4.8 09ee9da9cc feat(worker): fingerprint listing-less ZIP candidates via destination copy
Rebuild-created placeholder candidates have no PackageFile CRCs, so
name-side fingerprinting can't confirm them. When the stored candidate
fingerprint is incomplete, read the candidate's own copy from its
destination message and fingerprint against that instead of falling
straight to name+size confidence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:27:07 +02:00
adminandClaude Opus 4.8 7ddf13053f feat(worker): opportunistic provenance backfill during scan
Wire tryProvenanceBackfill into processOneArchiveSet: before downloading
a scanned ZIP/RAR/7Z, check whether it's the true origin of a
placeholder-provenance package in the destination channel and backfill
in place, skipping the download. Add the zipsBackfilled counter through
PipelineContext, updateRunActivity, and completeIngestionRun.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:23:53 +02:00
adminandClaude Opus 4.8 4a7b9e2a09 feat(db): IngestionRun.zipsBackfilled counter
Additive migration (applied on deploy via prisma migrate deploy). Counts
packages whose provenance was backfilled during an ingestion run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:20:07 +02:00
adminandClaude Opus 4.8 9a06130c5b feat(worker): provenance-backfill orchestrator
Implements tryProvenanceBackfill() per Task 6 of the provenance-backfill
plan: looks up a placeholder candidate by fileName+fileSize, confirms ZIP
candidates via a ranged central-directory CRC32 fingerprint, and falls
back to name+size confidence for RAR/7z/failed listings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:23:15 +02:00
adminandClaude Opus 4.8 a7aa4ce285 feat(worker): DB helpers for provenance backfill
Adds findPlaceholderCandidate, getPackageFileCrcs, and
backfillProvenance to worker/src/db/queries.ts (Task 5). Candidate
predicate matches placeholder packages by source==dest or the
sourceMessageId==0 rebuild sentinel; backfillProvenance re-checks
placeholder status inside the transaction before overwriting fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:12:41 +02:00
adminandClaude Opus 4.8 38072d250f feat(worker): ranged TDLib file download (downloadFileRange)
Implements Task 4 Step 2 of the provenance-backfill plan. The live
spike (Step 1) and manual verification (Step 3) were not run in this
environment because a second TDLib client would corrupt the running
worker's authenticated session; the absolute-offset assumption is
noted as pending live verification on deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:12:37 +02:00
adminandClaude Opus 4.8 0bce1168a9 docs: correct provenance-backfill candidate predicate for rebuild records
Rebuild records use sourceMessageId=0 + synthetic 'rebuild:' contentHash and an
arbitrary fallback sourceChannelId, so the original sourceChannelId==destChannelId
candidate definition missed them. Predicate is now
(sourceChannelId==destChannelId OR sourceMessageId==0), verified against 59,893
live rebuilt records.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:05:34 +02:00
adminandClaude Opus 4.8 018b0f5d74 feat(worker): parse ZIP central directory from a tail buffer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:20:16 +02:00
adminandClaude Opus 4.8 c2590fb66f feat(worker): CRC32 archive fingerprint compare
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:19:28 +02:00
adminandClaude Opus 4.8 8b443620c8 test(worker): add vitest harness
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:18:57 +02:00
adminandClaude Opus 4.8 80aa2b0ee0 docs: provenance-backfill spec and implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:05:26 +02:00
adminandClaude Opus 4.8 f0e0e79d34 fix(auth): survive a stale session whose user was deleted
A JWT session pointing at a user no longer in the DB (e.g. after a DB reset)
made getUserSettings create settings for a non-existent user -> FK violation
(P2003) -> Server Component render crash. getUserSettings now returns defaults
on P2003; the (app) layout detects the missing user and redirects to a new
server-side /logout route that clears the cookie, avoiding the middleware
redirect loop that otherwise blocks reaching /login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:05:26 +02:00
adminandClaude Opus 4.8 21bd46010f feat(stls): flat package list, drop heuristic auto-grouping
Render the STL view as a flat per-package list (listDisplayItems no longer
collapses packages into group rows) and hide the Ungrouped tab, now that the
creator filter organizes the list. Remove the worker's heuristic auto-grouping
passes (rule/time/pattern/creator/zip-path/reply-chain/caption); album grouping
is kept. Existing groups and manual grouping actions are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:05:18 +02:00
admin 2252ac01f5 Add build-backup CI step, include it in deploy dependencies 2026-07-23 01:17:35 +02:00
adminandClaude Sonnet 5 63df348028 Fix db service network alias for backup.sh pg_dump connection
The backup.sh script hardcodes 'pg_dump -h dragonsstash-db' (matching
production), but the db service lacked a network alias for that hostname.
Added network alias to the backend network so the backup service can
successfully connect using the hardcoded hostname.

Verified: both 'db' and 'dragonsstash-db' resolve from the backend network.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 01:15:20 +02:00
adminandClaude Sonnet 5 8bbf51f056 Add backup service and nas_backups volume to docker-compose.yml
Wires the backup service (from Tasks 1-2) into the production compose
configuration, adds NFS-backed nas_backups volume, and documents the four
required env vars (NAS_HOST, NAS_EXPORT_PATH, RESTIC_PASSWORD, KUMA_PUSH_URL, TZ)
in .env.example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 01:09:35 +02:00
adminandClaude Sonnet 5 5873d148c1 Fix backup.sh: always clean up temp dump/tar files via EXIT trap
Previously the dump/tar cleanup only ran after restic forget succeeded,
so a plaintext Postgres dump could be left behind in /tmp if tar or
restic failed partway through. Add an EXIT trap that unconditionally
removes both temp files on any exit path, and drop the now-redundant
explicit rm -f from the success path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 01:06:22 +02:00
adminandClaude Sonnet 5 90be365c7f Implement backup.sh: pg_dump + tdlib tar + restic backup/forget + Kuma reporting
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 00:58:29 +02:00
admin 7e21a41615 Add backup service image (Dockerfile, entrypoint, crontab) 2026-07-23 00:53:34 +02:00
admin f3d62c68fb Add implementation plan for NAS-backed Postgres + TDLib backups
Six tasks: backup image, backup.sh, repo compose wiring, CI build
step, production compose wiring (gated on NAS/Kuma details from the
user), deploy + restore-drill verification.
2026-07-23 00:47:47 +02:00
admin 412e3066bc Add design spec for NAS-backed Postgres + TDLib backups
Restic-based backup container, encrypted daily backups to a Synology
NFS share, 14-day retention, Uptime Kuma alerting.
2026-07-23 00:41:06 +02:00
adminandClaude Opus 4.8 6178ff3b08 docs: add Task 3 (creator filter combobox) to plan
continuous-integration/drone/push Build is passing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:42:37 +02:00
admin 57842c6d95 Merge feat/send-all-from-creator: send-all-from-creator button + searchable creator filter 2026-07-04 09:42:08 +02:00
admin b3c49c3794 feat(stls): add searchable creator filter combobox to toolbar 2026-07-04 09:36:51 +02:00
admin 20e60bc6af feat(stls): add 'send all from creator' toolbar button 2026-07-04 09:26:36 +02:00
admin 50e89719bb feat(stls): add sendAllFromCreatorAction server action 2026-07-04 09:20:36 +02:00
adminandClaude Opus 4.8 1cae855c26 docs: implementation plan for send-all-from-creator button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:15:58 +02:00
adminandClaude Opus 4.8 b0baf72f0a docs: spec for send-all-from-creator button on STL page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:14:33 +02:00
adminandClaude Opus 4.8 0cf5fcd3a7 fix(stls): stop loading preview blobs in list queries (OOM fix)
continuous-integration/drone/push Build is passing
The STLs list queries selected the full previewData JPEG bytes (avg
~700KB, up to 2MB, and every group member's preview) only to compute a
hasPreview boolean. Under concurrent page loads this piled blobs into
the Node heap, exceeded the 512MB container limit, crashed the process,
and surfaced as repeated connection errors while browsing.

Replace the byte-loading select with a fetchPreviewFlags() helper that
checks `previewData IS NOT NULL` in SQL (IDs only, no bytes). Applied to
listPackages, listDisplayItems, searchPackages, and listUngroupedPackages.

Verified: same concurrent load that drove memory to 508/512MB and forced
a restart now peaks at 156MB (30%) with zero failed requests and no restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:05:34 +02:00
adminandClaude Opus 4.8 3b7202a662 harden(telegram): reject non-positive topic id in disableActiveTopic
continuous-integration/drone/push Build is passing
BigInt("") / BigInt("0") don't throw; guard against them since the
action is exported and admin-callable independent of the UI button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:15:03 +02:00
adminandClaude Opus 4.8 23f8e91c50 feat(telegram): add "skip & disable topic" on the live worker panel
Track the forum topic currently being processed on IngestionRun
(currentTopicId + currentAccountChannelMapId; additive migration) and
expose it on the live status. processArchiveSets gains an optional
shouldStop callback polled before each archive set; the forum branch
passes a live isTopicFetchEnabled check, so disabling a topic mid-run
lets the in-flight file finish, then skips the rest of that topic.

A new disableActiveTopic server action sets the topic's fetchEnabled
false (upsert), and the worker status panel shows a "Skip & disable
this topic" button while a topic is being processed. Future runs skip
the topic via the existing live per-topic read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:10:56 +02:00
admin c749d03376 Merge fix/topics-drawer-scroll-and-filter: topics drawer scroll + title filter
continuous-integration/drone/push Build is passing
2026-06-22 09:05:46 +02:00
adminandClaude Opus 4.8 d7771887a1 fix(worker): silence false-positive integrity-test notifications
The pre-upload integrity test is advisory (never blocks upload), but it
raised a WARNING SystemNotification whenever `7z t` failed — most often
because large 7z archives OOM-kill the test process (SIGKILL / exit 137)
in the memory-limited worker container, which is a tool limitation, not
corruption. Classify failures as encrypted | corrupt | inconclusive;
suppress notifications for inconclusive (debug log only) while still
proceeding with the upload as before. Genuine corruption now uses the
INTEGRITY_AUDIT notification type instead of the misleading HASH_MISMATCH;
encrypted archives still notify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:57:25 +02:00
adminandClaude Opus 4.8 26bc43299a fix(telegram): make topics drawer scrollable + add topic title filter
The Radix ScrollArea viewport did not get a bounded height inside the
flex-column, max-h, vertically-centred dialog, so a long topic list
overflowed the dialog instead of scrolling (last topics unreachable).
Switch to a native flex-1 min-h-0 overflow-y-auto container, which is
the canonical touch-friendly flex-scroll pattern.

Also add a "Filter topics..." input (matches by topic title) so any
topic can be reached regardless of list length; reset the filter on
close; and add a toast fallback message on toggle failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:50:10 +02:00
adminandClaude Opus 4.8 8b500a1610 feat(stls): enlarge candidate images in preview picker
continuous-integration/drone/push Build is passing
Each loaded candidate gets an enlarge button that opens the ImageLightbox
without changing selection, so the right preview is easier to choose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:57:40 +02:00
adminandClaude Opus 4.8 f0a9d3b4da feat(stls): click-to-enlarge preview in package files drawer
The drawer preview image opens the ImageLightbox; replacing the image
stays available via the existing Upload/Pick Preview buttons. No-preview
upload affordance unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:48:13 +02:00
adminandClaude Opus 4.8 b90317c007 feat(stls): click-to-enlarge package preview thumbnail in table
Package preview thumbnails open the ImageLightbox on click (hover shows a
maximize affordance). No-preview cells unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:44:52 +02:00
adminandClaude Opus 4.8 6f8ddcca81 feat(stls): add reusable ImageLightbox for full-size preview viewing
Radix Dialog-based lightbox (Esc / overlay / close button to dismiss),
image shown object-contain capped to the viewport. No new dependencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:17:42 +02:00
adminandClaude Opus 4.8 25ff067ea0 feat(telegram): add Topics action to forum channels in Channels tab
Forum source channels get a Topics menu item that opens the TopicsDrawer.
Non-forum channels are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:15:46 +02:00
adminandClaude Opus 4.8 7e58cc29fa feat(telegram): add TopicsDrawer for per-topic fetch toggles
Lists a channel's topics with an enable/disable Switch each (optimistic,
revert on failure). Empty state explains topics appear after the next scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:24:57 +02:00
adminandClaude Opus 4.8 5a4e358eee feat(telegram): add setTopicFetchEnabled server action
Admin-guarded toggle of TopicProgress.fetchEnabled by row id. Persists
immediately; the worker honours it on its next live per-topic read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:22:45 +02:00
adminandClaude Opus 4.8 c31afc5b92 feat(telegram): add GET /api/telegram/channels/[channelId]/topics
Returns per-topic fetch state for a channel, mirroring the existing
account-links route auth pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:19:46 +02:00
adminandClaude Opus 4.8 6324d64870 feat(telegram): expose isForum on ChannelRow and add listChannelTopics
ChannelRow now carries isForum so the UI can show the Topics action for
forum channels only. listChannelTopics returns per-topic fetch state for
a channel from TopicProgress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:16:42 +02:00
adminandClaude Opus 4.8 974769350b feat(worker): honour per-topic fetch toggle live in scan loop
Eager-persist a TopicProgress row for every discovered topic at run
start, and read the live fetchEnabled flag per topic so a mid-run
disable skips topics not yet started. Disabled topics are skipped
before any TDLib scan or fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:13:10 +02:00
adminandClaude Opus 4.8 f9b82f1654 feat(worker): add ensureTopicProgressRows + isTopicFetchEnabled helpers
ensureTopicProgressRows inserts missing topic rows only (skipDuplicates),
leaving watermarks/scan-state/fetchEnabled untouched. isTopicFetchEnabled
reads the live per-topic flag for mid-run skip decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:27:24 +02:00
adminandClaude Opus 4.8 7146a5cf0d feat(db): add fetchEnabled flag to TopicProgress
Additive Postgres migration (default true) that backs the per-topic
fetch enable/disable toggle. Existing rows backfill to enabled, so
current behaviour is unchanged. Migration applied to the live DB via
CI prisma migrate deploy (deferred, per environment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:25:21 +02:00
adminandClaude Opus 4.7 4f6a6f0f75 feat(worker): forum-topic scan-skip + getForumTopic short-circuit
continuous-integration/drone/push Build is passing
Mirror of the non-forum guards from 1a4bc6f, scoped to forum topics
inside the topic loop:

  - Top-of-topic-loop recency/backoff skip
  - getForumTopic short-circuit after the SkippedPackage retry pass
  - upsertTopicScanState for end-of-scan persistence (both the
    archives-found path and the no-archives path)

Same trulyIdle definition throughout: no archives this scan, no
failures this scan, no retryable SkippedPackage rows pending. Topics
with chronic failures stay out of backoff because their counter
never increments.

For MPE specifically (1,086 forum topics), per-cycle searchChatMessages
calls drop from ~1,086 to roughly the count of topics with new
activity in the last 5 minutes — typically <50.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:58:44 +02:00
adminandClaude Opus 4.7 1a4bc6f9f3 feat(worker): non-forum channel-scan-skip + getChat short-circuit
For non-forum channels in runWorkerForAccount, three guards:

  1. Top-of-loop recency/backoff skip — if recently scanned with no
     pending work, or in backoff and not its turn, skip entirely.
     Bypassed when retryable SkippedPackages exist.

  2. After the SkippedPackage retry pass, a getChat short-circuit —
     if TDLib's local cache says the channel's last_message.id <= our
     effective watermark, skip the paginated searchChatMessages.

  3. End-of-scan persists lastScannedAt + lastScanFoundArchives +
     consecutiveEmptyScans via the new upsertChannelScanState helper.
     trulyIdle requires: no archives, no failures, no retryable pending.

scheduler.ts exposes getCurrentCycle() so the backoff "every Nth cycle"
modulo can be applied.

Forum-topic branch lands in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:56:54 +02:00
adminandClaude Opus 4.7 c6b23715e8 feat(tdlib): add getChannelLastMessageId / getForumTopicLastMessageId
Both read the server-side last message ID from TDLib's local cache.
Used by the channel-scan-skip guard to short-circuit a paginated
searchChatMessages when last_message.id <= our watermark.

getForumTopic uses forum_topic_id (renamed from message_thread_id in
TDLib 1.8.64, same pattern as searchChatMessages / getForumTopics).

Returns null on any failure so the caller can fall back to scanning —
we'd rather waste a scan than miss new content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:54:53 +02:00
adminandClaude Opus 4.7 3111d658f8 feat(db): add upsertChannelScanState / upsertTopicScanState helpers
Wraps the existing watermark write with the three new scan-state
columns from the previous commit. Single transaction, sets
lastScannedAt=NOW() server-side. Caller is responsible for computing
the trulyIdle bool and the new consecutiveEmptyScans value
(pre-increment vs reset).

Existing updateLastProcessedMessage / upsertTopicProgress are kept for
callers that don't need the new fields (the SkippedPackage retry pass,
which only adjusts the watermark).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:53:50 +02:00
adminandClaude Opus 4.7 6652fb8bc4 feat(config): add three scan-skip tuning env vars
WORKER_SKIP_RECENT_SCAN_WINDOW_MS    (default 300000 = 5 min)
  WORKER_EMPTY_SCAN_BACKOFF_THRESHOLD  (default 5 cycles)
  WORKER_EMPTY_SCAN_BACKOFF_EVERY_NTH  (default 5)

All optional with safe defaults. Not yet read by any code — the worker
integration lands in follow-up commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:53:12 +02:00
adminandClaude Opus 4.7 ff846b8e8e feat(db): add scan-state columns to AccountChannelMap + TopicProgress
Three new fields on each table:
  - lastScannedAt          — when the worker last touched this scope
  - lastScanFoundArchives  — true if last scan had archives OR pending
                             retryables; tracks "work might need revisit"
  - consecutiveEmptyScans  — counter for cold-channel backoff

Schema change only. Worker logic in follow-up commits. Migration is a
metadata-only ALTER (NOT NULL with default) so it runs in ms even on
21k+ Package rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:52:28 +02:00
adminandClaude Opus 4.7 3be3509151 docs: add implementation plan for channel-scan skip optimization
7-task plan covering schema migration, config knobs, DB + TDLib helpers,
and wiring the skip guards + getChat/getForumTopic short-circuits into
both the forum and non-forum branches of runWorkerForAccount.

Each task ends with a type-check step before its commit so the tree
compiles after every step. Task 7 is manual verification covering
restart safety, failure-retry preservation, and backoff behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:45:46 +02:00
adminandClaude Opus 4.7 6223c47549 docs: add channel-scan skip optimization design spec
Design for adding lastScannedAt + lastScanFoundArchives +
consecutiveEmptyScans columns to AccountChannelMap and TopicProgress,
plus a getChat / getForumTopicInfo short-circuit before
searchChatMessages.

Goal: on restart and during cold-channel cycles, skip scanning channels
and forum topics that have nothing new. For MPE specifically, drops
the per-cycle API call count from ~1,086 to ~50.

Key safety rule: "truly idle" requires both no new archives AND no
retryable SkippedPackage rows pending. The 901f32f retry pass continues
to run unchanged. Failure retries are never skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:26:36 +02:00
adminandClaude Opus 4.7 13b261c0c8 fix(worker): make pre-upload integrity test advisory, not a hard gate
Diagnosed from production: main was rejecting almost every 7z file with
exit code 137 — kernel OOM-killing 7z t mid-test. p7zip needs to
decompress into memory to verify CRCs; ~1.5GB+ 7z archives with solid
compression exhaust the container's RAM and get SIGKILL'd.

Plus the multipart ZIP false-positive from yesterday (unzip -t can't
span .zip.001 chunks).

Both failure modes are tool limitations, not actual corruption. But
the integrity test in 04effed was a hard gate that THREW on any
non-success, blocking the upload. Result: dozens of valid archives
downloaded then thrown away over the past 6 hours.

This commit demotes the test from gate → advisory:

  - Failures get logged at warn level with the actual reason
  - A SystemNotification is emitted so the admin sees them in the UI
  - Encrypted archives get a clearer notification title but STILL
    proceed (the existing UI gives the user a way to see what's
    encrypted and decide what to do)
  - Upload proceeds normally — we have hash verification + archive
    metadata parse for the structural integrity signals we actually
    need

Multipart ZIPs are still skipped entirely (they can't be tested at
all without concatenation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:57:12 +02:00
adminandClaude Opus 4.7 25a6196262 fix(worker): skip integrity test for multipart ZIPs — unzip -t can't span them
continuous-integration/drone/push Build is passing
Diagnosed from production: main downloaded several 28 GB ZIP sets
(CA 3D STUDIOS 2023-07.zip.001..007, 2023-08.zip.001..006, ...) and
rejected every one of them with:

  "Archive integrity check failed: Command failed:
   unzip -tqq /tmp/zips/.../CA 3D STUDIOS 2023-07.zip.001"

Root cause: the integrity test I added in 04effed passed `uploadPaths[0]`
to the archive tester. For byte-split multipart ZIPs (`.zip.001`,
`.zip.002`, ...), the first chunk isn't a valid ZIP on its own — the
central directory only exists at the END of the assembled archive.
unzip's spanned-ZIP support uses `.z01/.z02/.../.zip` naming, not
`.zip.001/.002`, so even pointing at the assembled-form parts wouldn't
help.

Three correctness changes:

  1. Test runs on `tempPaths[0]` (the original downloaded file) instead
     of `uploadPaths[0]` (which may be byte-split chunks we created).
     For single-file ZIPs we re-split, this still tests the unsplit
     original.

  2. Skip the test entirely when archiveType=ZIP AND tempPaths.length>1
     — these are source multipart ZIPs we can't validate without
     concatenating, and the hash check + central-directory parse we
     already do are sufficient structural signals.

  3. RAR and 7Z multipart still ARE tested — `unrar t` and `7z t` both
     auto-discover sibling parts when pointed at the first one.

This unblocks all multipart-ZIP ingestion for the main account. Hours
of downloaded archives that were being rejected will now pass through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:15:07 +02:00
adminandClaude Opus 4.7 166dc556c9 fix(worker): match old General-topic progress rows under new TDLib forum_topic_id
continuous-integration/drone/push Build is passing
After the TDLib 1.8.50 → 1.8.64 upgrade, the worker now correctly
enumerates all forum topics in MPE (1,086 of them) — a huge win. But a
data-shape mismatch was about to bite us: TDLib changed how the
General topic is identified.

  TDLib 1.8.50: info.message_thread_id = 1048576  (magic constant)
  TDLib 1.8.64: info.forum_topic_id    = 1

Existing topic_progress rows for General carry topicId=1048576. The
worker looks up progress via `topicProgressList.find(tp => tp.topicId === topic.topicId)`,
which fails for General under the new TDLib → progress becomes null →
the scan starts from message 0.

For MPE specifically, that means re-scanning all ~378k General-topic
messages. Dedup catches the previously-ingested ones (no double upload),
but it burns hours of bandwidth before the watermark catches up.

Fix: when topicId lookup misses for a topic named "General", fall back
to a name match. The first watermark write after that saves under the
new ID (1), so future runs hit the topicId match directly without the
fallback. The orphaned 1048576 row stays as harmless dead data — we
don't delete it in case a TDLib downgrade or revert ever happens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:43:39 +02:00
66 changed files with 9691 additions and 210 deletions
+16 -1
View File
@@ -54,9 +54,24 @@ steps:
password: password:
from_secret: gitea_password from_secret: gitea_password
- name: build-backup
image: plugins/docker
depends_on: [clone]
settings:
repo: git.samagsteribbe.nl/admin/dragonsstash-backup
registry: git.samagsteribbe.nl
dockerfile: backup/Dockerfile
tags:
- latest
- "${DRONE_COMMIT_SHA:0:8}"
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- name: deploy - name: deploy
image: alpine image: alpine
depends_on: [build-app, build-worker, build-bot] depends_on: [build-app, build-worker, build-bot, build-backup]
environment: environment:
SSH_KEY: SSH_KEY:
from_secret: ssh_key from_secret: ssh_key
+9
View File
@@ -36,3 +36,12 @@ TDLIB_STATE_DIR="/data/tdlib"
WORKER_MAX_ZIP_SIZE_MB=4096 WORKER_MAX_ZIP_SIZE_MB=4096
MULTIPART_TIMEOUT_HOURS=0 MULTIPART_TIMEOUT_HOURS=0
LOG_LEVEL="info" LOG_LEVEL="info"
# Backup (NAS via SMB/CIFS + restic)
NAS_HOST="" # Synology NAS IP or hostname reachable from this host
NAS_SHARE="" # SMB share name, e.g. dragonsstash_backups
NAS_USERNAME="" # SMB user with read/write on the share
NAS_PASSWORD="" # SMB user password (avoid commas — they delimit cifs mount opts)
RESTIC_PASSWORD="" # generate with: openssl rand -base64 32
KUMA_PUSH_URL="" # optional: Uptime Kuma Push monitor URL; leave empty to disable alerting
TZ="Etc/UTC"
+13
View File
@@ -0,0 +1,13 @@
FROM alpine:3.20
# Note: use busybox's built-in crond (Alpine base), NOT the dcron package —
# dcron's crond fails with "setpgid: Operation not permitted" in this runtime.
RUN apk add --no-cache restic postgresql16-client curl tzdata tar bash
COPY backup/backup.sh /backup.sh
COPY backup/entrypoint.sh /entrypoint.sh
COPY backup/crontab /etc/crontabs/root
RUN chmod +x /backup.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
report_failure() {
[ -n "${KUMA_PUSH_URL:-}" ] || return 0
curl -fsS "$KUMA_PUSH_URL" --get \
--data-urlencode "status=down" \
--data-urlencode "msg=$BASH_COMMAND failed" || true
}
trap report_failure ERR
DUMP_FILE=/tmp/dragonsstash.dump
TAR_FILE=/tmp/tdlib.tar.gz
trap 'rm -f "$DUMP_FILE" "$TAR_FILE"' EXIT
pg_dump -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc -f "$DUMP_FILE"
# 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 \
|| { rc=$?; [ "$rc" -le 1 ] || exit "$rc"; }
restic backup "$DUMP_FILE" "$TAR_FILE"
restic forget --keep-daily 14 --prune
if [ -n "${KUMA_PUSH_URL:-}" ]; then
curl -fsS "$KUMA_PUSH_URL" --get \
--data-urlencode "status=up" \
--data-urlencode "msg=OK"
fi
+2
View File
@@ -0,0 +1,2 @@
0 3 * * * /backup.sh >> /proc/1/fd/1 2>&1
0 4 * * 0 restic check >> /proc/1/fd/1 2>&1
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
set -uo pipefail
# Ensure the repo exists, but never crash-loop on it: a transient error reading
# the repo (CIFS hiccup, stale lock) must not kill PID 1. `restic init` failing
# because the repo already exists is expected and harmless here.
if ! restic cat config >/dev/null 2>&1; then
restic init || echo "restic init skipped (repo already exists or temporarily unreachable)"
fi
exec crond -f -l 2
+38 -2
View File
@@ -97,6 +97,35 @@ services:
networks: networks:
- backend - backend
backup:
build:
context: .
dockerfile: backup/Dockerfile
pull_policy: never
environment:
- POSTGRES_USER=${POSTGRES_USER:-dragons}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-stash}
- PGPASSWORD=${POSTGRES_PASSWORD:-stash}
- POSTGRES_DB=${POSTGRES_DB:-dragonsstash}
- RESTIC_REPOSITORY=/backups/restic-repo
- RESTIC_PASSWORD=${RESTIC_PASSWORD:?Set RESTIC_PASSWORD in .env}
- KUMA_PUSH_URL=${KUMA_PUSH_URL:-}
- TZ=${TZ:-Etc/UTC}
volumes:
- tdlib_state:/data/tdlib-worker:ro
- tdlib_bot_state:/data/tdlib-bot:ro
- nas_backups:/backups
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
memory: 1G
networks:
- backend
db: db:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
@@ -116,8 +145,10 @@ services:
limits: limits:
memory: 1G memory: 1G
networks: networks:
- frontend frontend: {}
- backend backend:
aliases:
- dragonsstash-db
volumes: volumes:
postgres_data: postgres_data:
@@ -125,6 +156,11 @@ volumes:
tdlib_bot_state: tdlib_bot_state:
tmp_zips: tmp_zips:
manual_uploads: manual_uploads:
nas_backups:
driver_opts:
type: cifs
o: "username=${NAS_USERNAME},password=${NAS_PASSWORD},vers=3.0,uid=0,gid=0,file_mode=0660,dir_mode=0770"
device: "//${NAS_HOST}/${NAS_SHARE}"
networks: networks:
frontend: frontend:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,454 @@
# Send All From Creator 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 toolbar button on the STL packages page that queues every sendable package from the currently-filtered creator for delivery to the user's Telegram.
**Architecture:** A new server action `sendAllFromCreatorAction(creatorName)` mirrors the existing `sendAllInGroupAction`: it fetches sendable packages for the creator, dedups against live `BotSendRequest`s, creates a `BotSendRequest` per package, and fires `pg_notify('bot_send', id)`. The bot's existing `send-listener` consumes these unchanged. The client table renders a "Send all from [Creator]" button in the packages-tab toolbar only when `?creator=<name>` is active, wired to the action with a confirm dialog and a count toast.
**Tech Stack:** Next.js 16 App Router, TypeScript, Prisma v7, server actions, `pg_notify`, sonner toasts, lucide-react icons.
**Testing note:** This repo has no test framework (`CLAUDE.md`: "Testing is manual"). Verification for each task is `npm run lint` + `npm run build`, plus manual UI checks at the end. Do not scaffold a test harness.
---
### Task 1: Add `sendAllFromCreatorAction` server action
**Files:**
- Modify: `src/app/(app)/stls/actions.ts` (append new action after `sendAllInGroupAction`, which ends at line 591)
Reference implementation to mirror: `sendAllInGroupAction` at `src/app/(app)/stls/actions.ts:515-591`.
Existing imports already present in the file (do not re-add): `auth` from `@/lib/auth`, `prisma` from `@/lib/prisma`, `ActionResult` from `@/types/api.types`, `revalidatePath` from `next/cache`.
`ActionResult` is generic: `ActionResult<T = void>` (`src/types/api.types.ts`).
- [ ] **Step 1: Append the new action**
Add this to the end of `src/app/(app)/stls/actions.ts`:
```ts
export async function sendAllFromCreatorAction(
creatorName: string
): Promise<ActionResult<{ queued: number; skipped: number }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const creator = creatorName.trim();
if (!creator) {
return { success: false, error: "No creator specified" };
}
try {
const telegramLink = await prisma.telegramLink.findUnique({
where: { userId: session.user.id },
});
if (!telegramLink) {
return { success: false, error: "No linked Telegram account. Link one in Settings." };
}
const sendablePackages = await prisma.package.findMany({
where: {
creator,
destChannelId: { not: null },
destMessageId: { not: null },
},
select: { id: true },
});
if (sendablePackages.length === 0) {
return { success: false, error: "No uploaded packages found for this creator" };
}
let queued = 0;
let skipped = 0;
for (const pkg of sendablePackages) {
// Only create if no existing PENDING/SENDING request for this package+link combo
const existing = await prisma.botSendRequest.findFirst({
where: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
status: { in: ["PENDING", "SENDING"] },
},
});
if (existing) {
skipped++;
continue;
}
const sendRequest = await prisma.botSendRequest.create({
data: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
requestedByUserId: session.user.id,
status: "PENDING",
},
});
// Notify the bot via pg_notify
try {
await prisma.$queryRawUnsafe(
`SELECT pg_notify('bot_send', $1)`,
sendRequest.id
);
} catch {
// Best-effort — the bot also polls periodically
}
queued++;
}
revalidatePath("/stls");
return { success: true, data: { queued, skipped } };
} catch {
return { success: false, error: "Failed to send creator packages" };
}
}
```
- [ ] **Step 2: Verify lint + typecheck pass**
Run: `npm run lint`
Expected: no new errors in `src/app/(app)/stls/actions.ts`.
- [ ] **Step 3: Commit**
```bash
git add src/app/\(app\)/stls/actions.ts
git commit -m "feat(stls): add sendAllFromCreatorAction server action"
```
---
### Task 2: Wire up the toolbar button in the STL table
**Files:**
- Modify: `src/app/(app)/stls/_components/stl-table.tsx`
- lucide import (line 6)
- actions import block (lines 44-54)
- handler (after `handleSendAllInGroup`, which ends at line 278)
- `activeCreator` derivation (near `activeTag` at line 445)
- toolbar JSX (packages-tab toolbar row starting at line 478)
- [ ] **Step 1: Add the `Send` icon to the lucide import**
Change line 6 from:
```ts
import { Search, Layers, Upload } from "lucide-react";
```
to:
```ts
import { Search, Layers, Upload, Send } from "lucide-react";
```
- [ ] **Step 2: Import the new action**
In the actions import block (lines 44-54), add `sendAllFromCreatorAction` next to `sendAllInGroupAction`:
```ts
import {
updatePackageCreator,
updatePackageTags,
renameGroupAction,
dissolveGroupAction,
createGroupAction,
removeFromGroupAction,
sendAllInGroupAction,
sendAllFromCreatorAction,
updateGroupPreviewAction,
mergeGroupsAction,
} from "../actions";
```
- [ ] **Step 3: Derive the active creator from the URL**
Immediately after the `activeTag` line (`src/app/(app)/stls/_components/stl-table.tsx:445`):
```ts
const activeTag = searchParams.get("tag") ?? "";
```
add:
```ts
const activeCreator = searchParams.get("creator") ?? "";
```
- [ ] **Step 4: Add the click handler**
After `handleSendAllInGroup` (which ends at line 278), add:
```ts
const handleSendAllFromCreator = useCallback(() => {
if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return;
startTransition(async () => {
const result = await sendAllFromCreatorAction(activeCreator);
if (result.success) {
const { queued, skipped } = result.data;
toast.success(
`Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` +
(skipped ? ` (${skipped} already queued)` : "")
);
router.refresh();
} else {
toast.error(result.error);
}
});
}, [activeCreator, router]);
```
- [ ] **Step 5: Render the toolbar button**
In the packages-tab toolbar (`flex flex-wrap items-center gap-2` row at line 478), add the button right after the "Upload Files" button (which closes at line 507, before the `selectedPackages.size >= 2` block at line 508):
```tsx
{activeCreator && (
<Button
variant="outline"
size="sm"
className="h-9 gap-1.5"
onClick={handleSendAllFromCreator}
>
<Send className="h-3.5 w-3.5" />
Send all from {activeCreator}
</Button>
)}
```
- [ ] **Step 6: Verify lint + build pass**
Run: `npm run lint && npm run build`
Expected: no new errors; build completes.
- [ ] **Step 7: Manual verification**
1. `npm run dev`, open `/stls`.
2. Click a creator name in the Creator column to apply `?creator=<name>` (or navigate to `/stls?creator=<known creator>`).
3. Confirm the "Send all from [Creator]" button appears in the toolbar.
4. Remove the creator filter → confirm the button disappears.
5. Click the button → confirm the dialog appears; on confirm, a toast reports the queued count.
6. (If a linked Telegram account + uploaded packages exist) confirm packages arrive via the bot.
- [ ] **Step 8: Commit**
```bash
git add src/app/\(app\)/stls/_components/stl-table.tsx
git commit -m "feat(stls): add 'send all from creator' toolbar button"
```
---
### Task 3: Searchable creator filter combobox (makes the filter reachable)
**Why:** Discovered during execution — the `?creator=` filter that reveals the Task 2
button had no UI trigger. The creator table cell click only opens the edit prompt
(`package-columns.tsx:339``onSetCreator`). This task adds a searchable
"All Creators" combobox to the packages-tab toolbar (like the existing Tags select,
but type-to-filter since there can be many creators). The creator cell stays
edit-only.
**Files:**
- Modify: `src/lib/telegram/queries.ts` — add `getAllPackageCreators()`.
- Modify: `src/app/(app)/stls/page.tsx` — fetch + pass `availableCreators`.
- Create: `src/app/(app)/stls/_components/creator-filter.tsx` — the combobox.
- Modify: `src/app/(app)/stls/_components/stl-table.tsx` — new prop, handler, render.
- [ ] **Step 1: Add the distinct-creators query**
Append to `src/lib/telegram/queries.ts` (mirrors `getAllPackageTags` at line 530):
```ts
export async function getAllPackageCreators(): Promise<string[]> {
const result = await prisma.$queryRaw<{ creator: string }[]>`
SELECT DISTINCT creator FROM packages
WHERE creator IS NOT NULL AND creator <> ''
ORDER BY creator
`;
return result.map((r) => r.creator);
}
```
- [ ] **Step 2: Create the combobox component**
Create `src/app/(app)/stls/_components/creator-filter.tsx` (mirrors the
Popover+Command pattern in `src/components/shared/data-table-faceted-filter.tsx`):
```tsx
"use client";
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface CreatorFilterProps {
creators: string[];
value: string; // active creator, "" when none
onChange: (creator: string) => void; // "" clears the filter
}
export function CreatorFilter({ creators, value, onChange }: CreatorFilterProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className="h-9 w-[200px] justify-between"
>
<span className="truncate">{value || "All Creators"}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="Search creators..." className="h-9" />
<CommandList>
<CommandEmpty>No creators found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__all__"
onSelect={() => {
onChange("");
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")}
/>
All Creators
</CommandItem>
{creators.map((creator) => (
<CommandItem
key={creator}
value={creator}
onSelect={() => {
onChange(creator);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === creator ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{creator}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
```
- [ ] **Step 3: Wire the query through the page**
In `src/app/(app)/stls/page.tsx`:
- Add `getAllPackageCreators` to the import from `@/lib/telegram/queries` (line 3).
- Add `getAllPackageCreators()` to the `Promise.all` (line 27) and destructure
`availableCreators`:
```ts
const [result, ingestionStatus, availableTags, availableCreators, skippedCount, ungroupedCount] =
await Promise.all([
// ...existing entries unchanged...
getIngestionStatus(),
getAllPackageTags(),
getAllPackageCreators(),
countSkippedPackages(),
countUngroupedPackages(),
]);
```
(Insert `getAllPackageCreators()` immediately after `getAllPackageTags()`, and add
`availableCreators` in the matching position of the destructure.)
- Pass the prop to `<StlTable>`: `availableCreators={availableCreators}` (next to
`availableTags={availableTags}`).
- [ ] **Step 4: Add prop + handler + render in the table**
In `src/app/(app)/stls/_components/stl-table.tsx`:
- Import the component: `import { CreatorFilter } from "./creator-filter";`
- Add to `StlTableProps` (next to `availableTags: string[];`): `availableCreators: string[];`
- Add to the destructured params (next to `availableTags,`): `availableCreators,`
- Add the handler after `updateTagFilter` (ends ~line 210):
```ts
const updateCreatorFilter = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("creator", value);
params.set("page", "1");
} else {
params.delete("creator");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
},
[router, pathname, searchParams]
);
```
- Render it in the toolbar right after the Tags `Select` block (the
`{availableTags.length > 0 && (...)}` block, ~lines 507-522):
```tsx
{availableCreators.length > 0 && (
<CreatorFilter
creators={availableCreators}
value={activeCreator}
onChange={updateCreatorFilter}
/>
)}
```
- [ ] **Step 5: Verify + manual test**
Run: `npx tsc --noEmit` (filter for the touched files — expect none new). Note: full
`npm run build` fails on a pre-existing stale Prisma client issue in
`src/lib/telegram/*` unrelated to this change; do not attempt to fix it.
Manual: open `/stls`, use the "All Creators" combobox, type to filter, pick a
creator → list filters and the "Send all from [Creator]" button appears; pick
"All Creators" → filter clears and button disappears.
- [ ] **Step 6: Commit**
```bash
git add "src/lib/telegram/queries.ts" "src/app/(app)/stls/page.tsx" \
"src/app/(app)/stls/_components/creator-filter.tsx" \
"src/app/(app)/stls/_components/stl-table.tsx"
git commit -m "feat(stls): add searchable creator filter combobox to toolbar"
```
---
## Self-Review
**Spec coverage:**
- Req 1 (button when filtered by creator) → Task 2 Step 5 (`{activeCreator && ...}`).
- Req 2 (hidden when no filter) → Task 2 Step 5 conditional.
- Req 3 (confirm dialog) → Task 2 Step 4 `confirm(...)`.
- Req 4 (all pages, not current page) → Task 1 queries `prisma.package.findMany` by `creator`, unpaginated.
- Req 5 (skip not-uploaded + dedup live requests) → Task 1 `where` filter on `destChannelId`/`destMessageId` + `existing` check.
- Req 6 (report counts) → Task 1 returns `{ queued, skipped }`; Task 2 Step 4 toast.
- Req 7 (no polling) → Task 2 handler queues + `router.refresh()`, no poll loop.
- Blank-creator guard → Task 1 `creator.trim()` check.
**Placeholder scan:** No TBD/TODO/placeholder steps; all code shown in full.
**Type consistency:** Action returns `ActionResult<{ queued: number; skipped: number }>`; handler destructures `result.data.{queued,skipped}` inside the `result.success` branch (where `data` is typed). `sendAllFromCreatorAction` name matches between Task 1 definition, Task 2 import, and Task 2 call site. `activeCreator` defined once (Step 3) and used in Steps 4-5.
@@ -0,0 +1,555 @@
# NAS Backup for Postgres + TDLib State 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 `backup` container to the DragonsStash stack that takes daily, encrypted, deduplicated backups of the Postgres database and both TDLib state volumes, and ships them to a Synology NAS over NFS.
**Architecture:** A small Alpine-based image (restic + postgresql16-client + curl + dcron) runs as its own compose service. A crontab fires `backup.sh` daily at 03:00, which dumps Postgres, tars the TDLib volumes, hands both to `restic backup` against an NFS-backed Docker volume, prunes with `restic forget --keep-daily 14`, and reports success/failure to an Uptime Kuma push monitor. Matches the existing `worker`/`bot` pattern: build context in the repo's `docker-compose.yml`, prebuilt image in `/opt/stacks/DragonsStash/docker-compose.yml`, built and pushed by `.drone.yml`.
**Tech Stack:** Alpine 3.20, restic 0.16, postgresql16-client, dcron, bash, Docker Compose NFS volume driver.
## Global Constraints
- Retention: `restic forget --keep-daily 14 --prune` (14-day window, per approved spec).
- Schedule: daily backup at 03:00, weekly `restic check` at 04:00 Sunday.
- No Docker socket mount, no `privileged: true` — the backup container must not be able to control sibling containers.
- No host-level mount — NFS access only via Docker's native `driver_opts: type: nfs` volume, never `/etc/fstab`.
- Encryption and retention are restic's job — no hand-rolled `age`/`gpg`/`find -mtime` logic.
- TDLib volumes are tarred live (best-effort) — never pause `worker`/`bot` for the backup.
- Restore is a manual, documented procedure only — never scripted/automated.
**Required user input before Task 5 can run:** `NAS_HOST` and `NAS_EXPORT_PATH` (the Synology NFS share details) and a Kuma Push-monitor URL (`KUMA_PUSH_URL`, created manually in the existing Uptime Kuma instance, ~26h expected heartbeat interval). Tasks 14 need none of these and can proceed immediately; do not substitute placeholder values for them in Task 5 — stop and ask the user instead.
---
### Task 1: Backup image (Dockerfile + entrypoint)
**Files:**
- Create: `backup/Dockerfile`
- Create: `backup/entrypoint.sh`
- Create: `backup/crontab`
**Interfaces:**
- Produces: a buildable image tagged `dragonsstash-backup:test` locally, with `/entrypoint.sh` as `ENTRYPOINT`, `/backup.sh` present at the image root (written in Task 2 — this task only needs the `COPY` line and a placeholder-free stub isn't acceptable, so create an emptybody-but-real `backup/backup.sh` here containing just `#!/bin/bash` + `exit 0`, and Task 2 replaces its contents), `restic`, `pg_dump`/`pg_restore`, `curl`, `tar`, `bash`, `dcron` all on `PATH`.
- Consumes: nothing from earlier tasks.
- [ ] **Step 1: Write `backup/backup.sh` stub**
```bash
#!/bin/bash
set -euo pipefail
exit 0
```
- [ ] **Step 2: Write `backup/entrypoint.sh`**
```bash
#!/bin/bash
set -euo pipefail
if ! restic snapshots >/dev/null 2>&1; then
restic init
fi
exec crond -f -l 2
```
- [ ] **Step 3: Write `backup/crontab`**
```
0 3 * * * /backup.sh >> /proc/1/fd/1 2>&1
0 4 * * 0 restic check >> /proc/1/fd/1 2>&1
```
- [ ] **Step 4: Write `backup/Dockerfile`**
```dockerfile
FROM alpine:3.20
RUN apk add --no-cache restic postgresql16-client curl tzdata dcron tar bash
COPY backup/backup.sh /backup.sh
COPY backup/entrypoint.sh /entrypoint.sh
COPY backup/crontab /etc/crontabs/root
RUN chmod +x /backup.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
```
- [ ] **Step 5: Build the image**
Run: `cd /home/sam/Documents/DragonsStash && docker build -t dragonsstash-backup:test -f backup/Dockerfile .`
Expected: build completes with `Successfully tagged dragonsstash-backup:test` (or Buildkit's equivalent final `naming to docker.io/library/dragonsstash-backup:test done`), no errors.
- [ ] **Step 6: Verify the tools are present**
Run: `docker run --rm dragonsstash-backup:test restic version && docker run --rm dragonsstash-backup:test pg_dump --version`
Expected: `restic 0.16.x ...` and `pg_dump (PostgreSQL) 16.x` printed, both commands exit 0.
- [ ] **Step 7: Verify the entrypoint initializes an empty repo and starts cron**
```bash
mkdir -p /tmp/backup-repo-smoke
docker run -d --name backup-smoke \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=smoketest \
-v /tmp/backup-repo-smoke:/backups \
dragonsstash-backup:test
sleep 2
docker logs backup-smoke
docker exec backup-smoke restic snapshots
docker rm -f backup-smoke
rm -rf /tmp/backup-repo-smoke
```
Expected: `docker logs` shows no errors (restic init ran silently); `restic snapshots` prints an empty snapshot list (repo exists, header row only, no error).
- [ ] **Step 8: Commit**
```bash
git add backup/Dockerfile backup/entrypoint.sh backup/backup.sh backup/crontab
git commit -m "Add backup service image (Dockerfile, entrypoint, crontab)"
```
---
### Task 2: `backup.sh` script
**Files:**
- Modify: `backup/backup.sh` (replace Task 1's stub with the real script)
**Interfaces:**
- Consumes: the image built in Task 1 (`dragonsstash-backup:test`), rebuilt after this change.
- Produces: `/backup.sh`, invoked by cron in Task 1's `crontab` and manually in Task 6's verification. Reads env vars `POSTGRES_USER`, `PGPASSWORD`, `POSTGRES_DB`, `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, `KUMA_PUSH_URL`. Assumes network hostname `dragonsstash-db:5432` for Postgres and mounts `/data/tdlib-worker`, `/data/tdlib-bot` (read-only) for TDLib state.
- [ ] **Step 1: Replace `backup/backup.sh` with the real script**
```bash
#!/bin/bash
set -euo pipefail
report_failure() {
curl -fsS "$KUMA_PUSH_URL" --get \
--data-urlencode "status=down" \
--data-urlencode "msg=$BASH_COMMAND failed" || true
}
trap report_failure ERR
DUMP_FILE=/tmp/dragonsstash.dump
TAR_FILE=/tmp/tdlib.tar.gz
pg_dump -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc -f "$DUMP_FILE"
tar czf "$TAR_FILE" -C /data tdlib-worker tdlib-bot
restic backup "$DUMP_FILE" "$TAR_FILE"
restic forget --keep-daily 14 --prune
rm -f "$DUMP_FILE" "$TAR_FILE"
curl -fsS "$KUMA_PUSH_URL" --get \
--data-urlencode "status=up" \
--data-urlencode "msg=OK"
```
- [ ] **Step 2: Rebuild the image**
Run: `docker build -t dragonsstash-backup:test -f backup/Dockerfile .`
Expected: build succeeds.
- [ ] **Step 3: Stand up a scratch Postgres aliased as `dragonsstash-db`**
```bash
docker network create backup-test-net 2>/dev/null || true
docker run -d --name test-pg --network backup-test-net --network-alias dragonsstash-db \
-e POSTGRES_USER=dragons -e POSTGRES_PASSWORD=stash -e POSTGRES_DB=dragonsstash \
postgres:16-alpine
sleep 5
docker exec test-pg pg_isready -U dragons -d dragonsstash
```
Expected: `accepting connections`.
- [ ] **Step 4: Stand up a mock Kuma push endpoint**
```bash
mkdir -p /tmp/mock-kuma-root && touch /tmp/mock-kuma-root/push
docker run -d --name mock-kuma --network backup-test-net \
-v /tmp/mock-kuma-root:/srv -w /srv python:3-alpine \
python3 -m http.server 8000
sleep 1
```
- [ ] **Step 5: Prepare fake TDLib state and a local restic repo dir**
```bash
mkdir -p /tmp/backup-test/tdlib-worker /tmp/backup-test/tdlib-bot /tmp/backup-test/repo
echo "fake-session" > /tmp/backup-test/tdlib-worker/state.bin
echo "fake-session" > /tmp/backup-test/tdlib-bot/state.bin
```
- [ ] **Step 6: Initialize the test restic repo and run `backup.sh` (success path)**
```bash
docker run --rm --network backup-test-net \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
-v /tmp/backup-test/repo:/backups \
--entrypoint restic dragonsstash-backup:test init
docker run --rm --network backup-test-net \
-e POSTGRES_USER=dragons -e POSTGRES_PASSWORD=stash -e PGPASSWORD=stash -e POSTGRES_DB=dragonsstash \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
-e KUMA_PUSH_URL=http://mock-kuma:8000/push \
-v /tmp/backup-test/tdlib-worker:/data/tdlib-worker:ro \
-v /tmp/backup-test/tdlib-bot:/data/tdlib-bot:ro \
-v /tmp/backup-test/repo:/backups \
--entrypoint /backup.sh dragonsstash-backup:test
echo "exit code: $?"
```
Expected: exit code `0`, restic prints a line like `snapshot xxxxxxxx saved`, no error output.
- [ ] **Step 7: Verify the snapshot landed and contains both files**
```bash
docker run --rm -v /tmp/backup-test/repo:/backups \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
--entrypoint restic dragonsstash-backup:test snapshots
docker run --rm -v /tmp/backup-test/repo:/backups \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
--entrypoint restic dragonsstash-backup:test ls latest
```
Expected: `snapshots` shows exactly one entry; `ls latest` lists `/tmp/dragonsstash.dump` and `/tmp/tdlib.tar.gz`.
- [ ] **Step 8: Verify the failure path reports to Kuma**
```bash
docker run --rm --network backup-test-net \
-e POSTGRES_USER=dragons -e POSTGRES_PASSWORD=wrongpass -e PGPASSWORD=wrongpass -e POSTGRES_DB=dragonsstash \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
-e KUMA_PUSH_URL=http://mock-kuma:8000/push \
-v /tmp/backup-test/tdlib-worker:/data/tdlib-worker:ro \
-v /tmp/backup-test/tdlib-bot:/data/tdlib-bot:ro \
-v /tmp/backup-test/repo:/backups \
--entrypoint /backup.sh dragonsstash-backup:test
echo "exit code: $?"
docker logs mock-kuma | tail -5
```
Expected: exit code nonzero (pg_dump auth failure trips `set -e`); `docker logs mock-kuma` shows a GET request line containing `status=down`.
- [ ] **Step 9: Clean up test resources**
```bash
docker rm -f test-pg mock-kuma
docker network rm backup-test-net
rm -rf /tmp/backup-test /tmp/mock-kuma-root
```
- [ ] **Step 10: Commit**
```bash
git add backup/backup.sh
git commit -m "Implement backup.sh: pg_dump + tdlib tar + restic backup/forget + Kuma reporting"
```
---
### Task 3: Wire the `backup` service into the repo's `docker-compose.yml`
**Files:**
- Modify: `docker-compose.yml`
- Modify: `.env.example`
**Interfaces:**
- Consumes: `backup/Dockerfile` (Task 1), `backup/backup.sh` (Task 2).
- Produces: a `backup` compose service buildable via `docker compose build backup`, and a `nas_backups` named volume other tasks (5) will mirror into the production compose file.
- [ ] **Step 1: Add the `backup` service to `docker-compose.yml`**
Insert after the existing `bot` service (before `db`):
```yaml
backup:
build:
context: .
dockerfile: backup/Dockerfile
pull_policy: never
environment:
- POSTGRES_USER=${POSTGRES_USER:-dragons}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-stash}
- PGPASSWORD=${POSTGRES_PASSWORD:-stash}
- POSTGRES_DB=${POSTGRES_DB:-dragonsstash}
- RESTIC_REPOSITORY=/backups/restic-repo
- RESTIC_PASSWORD=${RESTIC_PASSWORD:?Set RESTIC_PASSWORD in .env}
- KUMA_PUSH_URL=${KUMA_PUSH_URL:?Set KUMA_PUSH_URL in .env}
- TZ=${TZ:-Etc/UTC}
volumes:
- tdlib_state:/data/tdlib-worker:ro
- tdlib_bot_state:/data/tdlib-bot:ro
- nas_backups:/backups
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
networks:
- backend
```
- [ ] **Step 2: Add the `nas_backups` volume to the `volumes:` block**
```yaml
nas_backups:
driver_opts:
type: nfs
o: "addr=${NAS_HOST},rw,nfsvers=4,soft,timeo=100"
device: ":${NAS_EXPORT_PATH}"
```
- [ ] **Step 3: Document the new env vars in `.env.example`**
Append:
```
# Backup (NAS via NFS + restic)
NAS_HOST="" # Synology NAS IP or hostname reachable from this host
NAS_EXPORT_PATH="" # NFS export path, e.g. /volume1/dragonsstash-backups
RESTIC_PASSWORD="" # generate with: openssl rand -base64 32
KUMA_PUSH_URL="" # Uptime Kuma Push monitor URL (create the monitor first)
TZ="Etc/UTC"
```
- [ ] **Step 4: Validate the compose file parses**
Run (with dummy values so the `:?` guards don't fail parsing — `AUTH_SECRET` is required by the existing `app` service, not by this change, but `config` validates the whole file):
```bash
RESTIC_PASSWORD=dummy KUMA_PUSH_URL=http://dummy NAS_HOST=dummy NAS_EXPORT_PATH=/dummy AUTH_SECRET=dummy \
docker compose -f docker-compose.yml config --quiet
```
Expected: no output, exit code 0 (a syntax/interpolation error would print to stderr and exit nonzero).
- [ ] **Step 5: Validate the service actually builds through Compose**
Run:
```bash
RESTIC_PASSWORD=dummy KUMA_PUSH_URL=http://dummy NAS_HOST=dummy NAS_EXPORT_PATH=/dummy AUTH_SECRET=dummy \
docker compose -f docker-compose.yml build backup
```
Expected: build succeeds (reuses Task 1's image layers).
- [ ] **Step 6: Commit**
```bash
git add docker-compose.yml .env.example
git commit -m "Add backup service and nas_backups volume to docker-compose.yml"
```
---
### Task 4: CI — build and push the backup image
**Files:**
- Modify: `.drone.yml`
**Interfaces:**
- Consumes: `backup/Dockerfile` (Task 1).
- Produces: `git.samagsteribbe.nl/admin/dragonsstash-backup:latest` (and `:<short-sha>`), pushed on every push to `main`. Task 5's production compose file references this image tag.
- [ ] **Step 1: Add a `build-backup` step, mirroring `build-worker`/`build-bot`**
Insert after the existing `build-bot` step in `.drone.yml`:
```yaml
- name: build-backup
image: plugins/docker
depends_on: [clone]
settings:
repo: git.samagsteribbe.nl/admin/dragonsstash-backup
registry: git.samagsteribbe.nl
dockerfile: backup/Dockerfile
tags:
- latest
- "${DRONE_COMMIT_SHA:0:8}"
username:
from_secret: gitea_username
password:
from_secret: gitea_password
```
- [ ] **Step 2: Add `build-backup` to the `deploy` step's `depends_on`**
Change:
```yaml
- name: deploy
image: alpine
depends_on: [build-app, build-worker, build-bot]
```
to:
```yaml
- name: deploy
image: alpine
depends_on: [build-app, build-worker, build-bot, build-backup]
```
- [ ] **Step 3: Validate YAML syntax**
Run: `python3 -c "import yaml; yaml.safe_load(open('.drone.yml')); print('OK')"`
Expected: `OK` printed, no exception.
- [ ] **Step 4: Commit**
```bash
git add .drone.yml
git commit -m "Add build-backup CI step, include it in deploy dependencies"
```
---
### Task 5: Wire production (`/opt/stacks/DragonsStash`) — requires NAS + Kuma details from the user
**Do not start this task until the user has supplied `NAS_HOST` and `NAS_EXPORT_PATH` for the Synology NFS share, and has created an Uptime Kuma Push monitor (name it `dragonsstash-backup`, ~26h expected heartbeat interval) and shared its push URL. If any of these are missing, stop and ask — do not substitute placeholder values here, since this file drives the real deployment.**
**Files:**
- Modify: `/opt/stacks/DragonsStash/docker-compose.yml`
- Modify: `/opt/stacks/DragonsStash/.env`
**Interfaces:**
- Consumes: `git.samagsteribbe.nl/admin/dragonsstash-backup:latest` (published by Task 4's CI step once merged/pushed), the real `NAS_HOST`/`NAS_EXPORT_PATH`/`KUMA_PUSH_URL` values gathered above.
- Produces: a running `dragonsstash-backup` container on the production host, verified in Task 6.
- [ ] **Step 1: Generate `RESTIC_PASSWORD` and add all four new vars to `/opt/stacks/DragonsStash/.env`**
```bash
cd /opt/stacks/DragonsStash
printf '\n# Backup (NAS via NFS + restic)\nNAS_HOST="<value from user>"\nNAS_EXPORT_PATH="<value from user>"\nRESTIC_PASSWORD="%s"\nKUMA_PUSH_URL="<value from user>"\nTZ="Etc/UTC"\n' "$(openssl rand -base64 32)" >> .env
```
Replace the two `<value from user>` placeholders with the real NAS details and Kuma push URL before saving — this step cannot be completed with the literal placeholder text left in place.
- [ ] **Step 2: Add the `backup` service to `/opt/stacks/DragonsStash/docker-compose.yml`**
Insert after the existing `bot` service (before `db`):
```yaml
backup:
image: git.samagsteribbe.nl/admin/dragonsstash-backup:latest
container_name: dragonsstash-backup
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_USER:-dragons}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-stash}
- PGPASSWORD=${POSTGRES_PASSWORD:-stash}
- POSTGRES_DB=${POSTGRES_DB:-dragonsstash}
- RESTIC_REPOSITORY=/backups/restic-repo
- RESTIC_PASSWORD=${RESTIC_PASSWORD:?Set RESTIC_PASSWORD in .env}
- KUMA_PUSH_URL=${KUMA_PUSH_URL:?Set KUMA_PUSH_URL in .env}
- TZ=${TZ:-Etc/UTC}
volumes:
- tdlib_state:/data/tdlib-worker:ro
- tdlib_bot_state:/data/tdlib-bot:ro
- nas_backups:/backups
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
memory: 256M
networks:
- internal
```
- [ ] **Step 3: Add the `nas_backups` volume**
```yaml
nas_backups:
driver_opts:
type: nfs
o: "addr=${NAS_HOST},rw,nfsvers=4,soft,timeo=100"
device: ":${NAS_EXPORT_PATH}"
```
- [ ] **Step 4: Validate the production compose file parses with the real `.env`**
Run: `cd /opt/stacks/DragonsStash && docker compose config --quiet`
Expected: no output, exit code 0.
- [ ] **Step 5: Commit is not applicable here**`/opt/stacks/DragonsStash` is a deployed copy, not the git repo (confirm with `git -C /opt/stacks/DragonsStash status` — expect "not a git repository"). Skip committing; Task 6 deploys these file changes directly.
---
### Task 6: Deploy and verify
**Files:** none (operational task)
**Interfaces:**
- Consumes: everything from Tasks 15.
- Produces: a running, verified backup on the real NAS, and one completed restore drill.
- [ ] **Step 1: Confirm with the user before pushing/deploying**
Pushing to `main` triggers Drone CI to build all four images and deploy to the production host via SSH (`docker compose pull && docker compose up -d`). Confirm the user wants this to happen now before proceeding — this affects the live stack.
- [ ] **Step 2: Push to `main`**
```bash
cd /home/sam/Documents/DragonsStash
git push origin main
```
Expected: Drone pipeline runs `build-app`, `build-worker`, `build-bot`, `build-backup`, then `deploy`, all green. Check via the Drone UI or `drone build info admin/DragonsStash <build-number>` if the `drone` CLI is configured.
- [ ] **Step 3: Confirm the container is up on the production host**
```bash
ssh sam@192.168.68.68 "docker ps --filter name=dragonsstash-backup --format '{{.Names}}\t{{.Status}}'"
```
Expected: `dragonsstash-backup Up ...`.
- [ ] **Step 4: Trigger one manual backup run and confirm a snapshot lands on the NAS**
```bash
ssh sam@192.168.68.68 "docker exec dragonsstash-backup /backup.sh"
ssh sam@192.168.68.68 "docker exec dragonsstash-backup restic snapshots"
```
Expected: `backup.sh` exits 0; `restic snapshots` lists exactly one entry.
- [ ] **Step 5: Confirm the Uptime Kuma push monitor shows green**
Open the Uptime Kuma dashboard and check the `dragonsstash-backup` monitor's status is up with a recent heartbeat.
- [ ] **Step 6: Restore drill — prove the backup is actually restorable**
```bash
ssh sam@192.168.68.68 "docker exec dragonsstash-backup restic restore latest --target /tmp/restore-drill"
ssh sam@192.168.68.68 "docker exec dragonsstash-backup ls -la /tmp/restore-drill/tmp"
```
Expected: `/tmp/restore-drill/tmp/dragonsstash.dump` and `/tmp/restore-drill/tmp/tdlib.tar.gz` both present with nonzero size. Then, on a scratch Postgres (not the live `dragonsstash-db`), confirm the dump restores cleanly:
```bash
ssh sam@192.168.68.68 "docker run -d --rm --name restore-drill-pg --network dragonsstash_internal \
-e POSTGRES_USER=drill -e POSTGRES_PASSWORD=drill -e POSTGRES_DB=drill postgres:16-alpine"
ssh sam@192.168.68.68 "docker cp dragonsstash-backup:/tmp/restore-drill/tmp/dragonsstash.dump /tmp/dragonsstash.dump"
ssh sam@192.168.68.68 "docker cp /tmp/dragonsstash.dump restore-drill-pg:/tmp/dragonsstash.dump"
ssh sam@192.168.68.68 "docker exec -e PGPASSWORD=drill restore-drill-pg pg_restore -U drill -d drill --clean --if-exists /tmp/dragonsstash.dump"
ssh sam@192.168.68.68 "docker exec -e PGPASSWORD=drill restore-drill-pg psql -U drill -d drill -c '\\dt' | head -20"
ssh sam@192.168.68.68 "docker rm -f restore-drill-pg"
```
Expected: `pg_restore` completes without fatal errors; `\dt` lists the app's tables (e.g. `Package`, `User`, `TelegramLink`).
- [ ] **Step 7: Clean up drill artifacts**
```bash
ssh sam@192.168.68.68 "docker exec dragonsstash-backup rm -rf /tmp/restore-drill"
ssh sam@192.168.68.68 "rm -f /tmp/dragonsstash.dump"
```
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,353 @@
# Channel-Scan Skip Optimization — Design
**Goal:** stop the worker from re-scanning channels and forum topics that haven't changed since the last scan, especially on restart. Reduce the per-cycle API call count for the Model Printing Emporium channel (1,086 forum topics) from ~1,000+ to ~50.
**Non-goals:**
- Replacing polling with event-driven ingestion (`updateNewMessage`). That's a separate, larger design (Phase 2 in the original brainstorm).
- Surfacing per-channel scan history in the UI (also a separate, observability-only design).
**Architecture sketch:**
Add three persisted columns to `AccountChannelMap` and `TopicProgress`, plus one runtime `getChat`/`getForumTopicInfo` lookup before each scan. The new state survives restarts because it's in PostgreSQL; the lookup is a cheap TDLib local-cache call. Failure-retry semantics (`d99a506` + `901f32f`) must be preserved — a channel sitting on retryable `SkippedPackage` rows is never considered idle.
---
## Problem statement
### Today's behavior
Every ingestion cycle the worker walks every linked source channel for every authenticated account. For each channel/topic it calls TDLib's `searchChatMessages` paginated from `lastProcessedMessageId`. Even when nothing has changed since the previous scan:
- One `searchChatMessages` call (sometimes paginated) is still made
- For Model Printing Emporium, that's ~1,086 calls per cycle (one per forum topic)
- The 1-second `apiDelayMs` between pages multiplies the cost
- Most calls return zero new messages — the work is wasted
The cost is most acute right after a restart: the worker boots, runs recovery, then issues 1,000+ effectively-empty calls before any productive work happens.
### What we already track
- `AccountChannelMap.lastProcessedMessageId` — highest processed message ID (per non-forum channel, per account)
- `TopicProgress.lastProcessedMessageId` — same per forum topic
- Both are advanced incrementally per archive set (`77aeb4c`)
- Both are pulled back below failed messages by the `SkippedPackage` retry pass (`901f32f`)
### What we don't track and want to add
- When was the last scan?
- Did the last scan find any archives, OR is there outstanding retry work?
- How many cycles in a row have been totally idle?
These let us skip the scan entirely when nothing has changed.
---
## High-level approach
Three guards at the top of the per-channel and per-topic processing loops:
1. **DB-persistent "skip if recently scanned and truly idle"** — checks `lastScannedAt`, `lastScanFoundArchives`, and a `retryableSkippedCount` query. If all three say "nothing new, nothing failing", skip without any TDLib call.
2. **Adaptive backoff for cold channels**`consecutiveEmptyScans` counter. After it crosses a threshold, scan only every Nth cycle. Reset to 0 whenever the channel is "not idle".
3. **`chat.last_message.id` short-circuit** — if (1) and (2) don't skip but the channel's last server-side message ID matches our watermark, skip the `searchChatMessages` paginated call. This runs after the existing `SkippedPackage` retry pass, which pulls the watermark back below failures, so it correctly forces a scan when retries are pending.
The retry pass from `901f32f` is preserved untouched — it runs in front of these guards and adjusts the watermark, so retries always happen.
---
## Schema changes
### `AccountChannelMap` (worker/src/db/schema.prisma)
```prisma
model AccountChannelMap {
// ... existing fields ...
lastScannedAt DateTime?
lastScanFoundArchives Boolean @default(false)
consecutiveEmptyScans Int @default(0)
}
```
### `TopicProgress`
```prisma
model TopicProgress {
// ... existing fields ...
lastScannedAt DateTime?
lastScanFoundArchives Boolean @default(false)
consecutiveEmptyScans Int @default(0)
}
```
### Migration
```sql
-- Both tables get the same three columns. Existing rows get defaults:
-- lastScannedAt = NULL (next scan will populate)
-- lastScanFoundArchives = false (safe default — will be overwritten by next scan)
-- consecutiveEmptyScans = 0 (resets backoff for existing channels)
ALTER TABLE "account_channel_map"
ADD COLUMN "lastScannedAt" TIMESTAMP(3),
ADD COLUMN "lastScanFoundArchives" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consecutiveEmptyScans" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "topic_progress"
ADD COLUMN "lastScannedAt" TIMESTAMP(3),
ADD COLUMN "lastScanFoundArchives" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consecutiveEmptyScans" INTEGER NOT NULL DEFAULT 0;
```
NULL `lastScannedAt` means "never scanned" — every channel will be scanned the first cycle after deploy. Subsequent cycles benefit from the new fields.
---
## Configuration
Two new env vars in `worker/src/util/config.ts`:
```typescript
/** Window in which a recent successful empty scan lets us skip. Default 5 min. */
skipRecentScanWindowMs:
parseInt(process.env.WORKER_SKIP_RECENT_SCAN_WINDOW_MS ?? "300000", 10),
/** After this many consecutive empty scans, channel enters backoff mode. */
emptyScanBackoffThreshold:
parseInt(process.env.WORKER_EMPTY_SCAN_BACKOFF_THRESHOLD ?? "5", 10),
/** Backoff factor — N means "scan every Nth cycle once in backoff". */
emptyScanBackoffEveryNth:
parseInt(process.env.WORKER_EMPTY_SCAN_BACKOFF_EVERY_NTH ?? "5", 10),
```
All three are tunable per deployment without code changes.
---
## Decision logic per channel / topic
The skip decision sits at the top of each channel/topic iteration in `runWorkerForAccount`. It runs BEFORE the existing `SkippedPackage` retry pass.
```text
For each channel (or topic):
1. Query retryableSkippedCount for this scope (already a query we do elsewhere)
2. If retryableSkippedCount > 0:
Force scan (don't skip — failures need retry)
Proceed to existing flow (retry pass → scan)
3. Else if lastScannedAt is NULL:
Force scan (we've never touched this)
Proceed to existing flow
4. Else if Date.now() - lastScannedAt.getTime() < skipRecentScanWindowMs
AND lastScanFoundArchives === false:
Skip — recently scanned and truly idle
5. Else if consecutiveEmptyScans >= emptyScanBackoffThreshold
AND (cycleCount % emptyScanBackoffEveryNth !== 0):
Skip — channel is cold, not its turn to scan
6. Else:
Run the existing flow:
a. SkippedPackage retry pass (901f32f) — may pull watermark back
b. NEW: getChat (or getForumTopicInfo) — if last_message.id <= watermark, skip
c. searchChatMessages scan
```
`cycleCount` is the global ingestion-cycle counter from `scheduler.ts`. It already increments per cycle.
---
## End-of-scan bookkeeping
After every scan (whether it found archives or not), update the three new fields atomically with the existing watermark write:
```typescript
// "Truly idle" means: nothing new this scan AND nothing failed AND no leftover
// retryable failures. The retry-pending check is critical — without it, a
// scan that found no new archives but left SkippedPackage retries pending
// would be marked idle and incorrectly skipped next cycle.
const retryablePending = await getRetryableSkippedMessageIds({
accountId, sourceChannelId, topicId, cap: maxSkipAttempts,
});
const trulyIdle =
scanResult.archives.length === 0
&& minFailedId === null
&& retryablePending.length === 0;
const newConsecutiveEmpty = trulyIdle
? (prev.consecutiveEmptyScans ?? 0) + 1
: 0;
await upsertChannelOrTopicScanState({
// ... existing watermark fields ...
lastScannedAt: new Date(),
lastScanFoundArchives: !trulyIdle,
consecutiveEmptyScans: newConsecutiveEmpty,
});
```
The `consecutiveEmptyScans` counter resets to 0 the moment *anything* happens — archives found, archives failed, or unresolved retries pending. A channel with a chronically-failing archive (whose attemptCount is still below the cap) keeps the counter at 0 and never enters backoff.
If a SkippedPackage hits `attemptCount === maxSkipAttempts`, it's no longer "retryable pending" (it's been given up on), so the counter increments correctly. Same for SkippedPackages that get deleted via the UI's "retry" button — the counter behaves correctly without special-casing.
---
## `getChat` / `getForumTopicInfo` short-circuit
After the retry pass has finalized the effective watermark, but before `searchChatMessages`:
```typescript
// For non-forum channels:
const chat = await client.invoke({ _: "getChat", chat_id: Number(channel.telegramId) });
const channelLastMessageId = chat.last_message?.id;
if (channelLastMessageId && BigInt(channelLastMessageId) <= effectiveWatermark) {
// Nothing new server-side — skip the paginated search entirely.
// Still update lastScannedAt / consecutiveEmptyScans so the recent-scan
// skip kicks in next cycle.
await persistScanState({ trulyIdle: true });
continue;
}
// For forum topics:
const topicInfo = await client.invoke({
_: "getForumTopicInfo",
chat_id: Number(channel.telegramId),
message_thread_id: Number(topic.topicId),
});
const topicLastMessageId = topicInfo.info?.last_message_id;
if (topicLastMessageId && BigInt(topicLastMessageId) <= effectiveWatermark) {
await persistScanState({ trulyIdle: true });
continue;
}
```
`getChat` is served from TDLib's local cache (no network) for chats we've already loaded, which we do up front via `loadChats`. `getForumTopicInfo` is a single round-trip but much cheaper than a paginated `searchChatMessages` call.
The comparison is `<=` because the watermark is the highest message we've fully processed — if the server's last is the same, we're caught up.
This step is correct in the failure-retry case because the retry pass runs FIRST: if there were retryable failures, the retry pass pulled the watermark back below them, and `channelLastMessageId > effectiveWatermark` (since the failed message exists in TG), so we don't skip — we scan and re-pick-up the failure.
---
## Restart behavior
The improvements compose for restart safety:
| Scenario | Today | After this change |
|---|---|---|
| Restart 5 min after a clean cycle | ~2,000 API calls for MPE | ~10 calls (only retryable + truly-active topics) |
| Restart 1 hour later (one missed cycle) | ~2,000 API calls | `getChat` per channel + scan only those where `last_message.id > watermark` (≈ 50 for MPE) |
| Restart after long downtime (12h) | ~2,000 calls + lots of new content | `getChat` per channel, scan everything with new activity |
The three new columns are in PostgreSQL — they survive container restarts directly. `consecutiveEmptyScans = 47` for a cold topic stays at 47 across restart, so backoff continues to apply.
---
## Edge cases and their handling
### 1. Manual SkippedPackage retry via UI between cycles
The UI's `retrySkippedPackageAction` lowers the watermark and deletes the SkippedPackage. Next cycle: `retryableSkippedCount === 0` (the row is gone), but the watermark is lower than `chat.last_message.id` (the retried message exists in TG). So step 6 in the decision tree triggers a scan via the `getChat` check. ✓
### 2. SkippedPackage hits the attempt cap mid-cycle
Once `attemptCount === maxSkipAttempts`, the row is no longer in `getRetryableSkippedMessageIds` results. The channel correctly becomes idle-eligible. The capped SkippedPackage stays in the table as "permanently failed (manual retry only)" — that's the existing behavior. ✓
### 3. New SkippedPackage is created mid-cycle (e.g., an upload fails)
At the end of that scan, `retryablePending` includes the new row → `trulyIdle = false``lastScanFoundArchives = true` → next cycle does NOT skip. ✓
### 4. Channel/topic added after deploy
New rows in `AccountChannelMap` / `TopicProgress` have `lastScannedAt = NULL`, so step 3 in the decision tree always triggers a scan. After the first scan, the fields are populated normally. ✓
### 5. Clock skew / drift
The `lastScannedAt < 5 min ago` check uses `Date.now() - lastScannedAt.getTime()`. Both are application-side clocks (Node.js + PostgreSQL `NOW()` at write). A few seconds of drift doesn't matter; an hour of clock jump (rare but possible) just means one cycle either skips or re-scans — recoverable.
### 6. TDLib `getChat` returns stale data
TDLib's local cache could theoretically be stale (e.g., the account hasn't received the latest update yet). If `channelLastMessageId` is stale (lower than server reality), we'd skip a scan that should have happened. Mitigation: the next cycle's `getChat` likely has fresh data; the watermark guards correctness (we don't lose data, we just process it one cycle later). Acceptable.
### 7. `getForumTopicInfo` rate limit
Calling it per-topic could add up for channels with 1000+ topics. Mitigation: skip-on-recent-scan (step 4) eliminates the call for most topics; only "stale-but-was-active" topics get the call. Worst case is ~50 calls per cycle for MPE, comfortably under the 30 req/sec global limit.
### 8. Channel becomes a forum (or vice versa) between cycles
Existing code handles this — `isChatForum` is rechecked each cycle and `setChannelForum` updates the DB. The new fields live on the same rows, so no extra handling needed.
---
## File-level changes
### New / modified
- `prisma/schema.prisma` — add the six new columns
- `prisma/migrations/<timestamp>_channel_scan_state/migration.sql` — the ALTER TABLE
- `worker/src/util/config.ts` — three new env vars
- `worker/src/db/queries.ts` — new helpers:
- `getChannelScanState(mappingId)` and `getTopicScanState(topicProgressId)`
- `upsertChannelScanState(...)` and `upsertTopicScanState(...)`
- Both wrap the existing `updateLastProcessedMessage` / `upsertTopicProgress` so callers don't need to remember to update the new fields too.
- `worker/src/worker.ts` — top-of-loop skip checks in both the forum and non-forum branches, plus end-of-scan state writes
- `worker/src/tdlib/chats.ts` — small helper `getChatLastMessageId(client, chatId)` and `getForumTopicLastMessageId(client, chatId, topicId)` wrapping the TDLib calls with the existing `invokeWithTimeout` pattern
### Untouched
- `recovery.ts` — recovery is per-startup and one-shot; not affected
- `scheduler.ts``cycleCount` is already there; just expose it where needed
- The existing `SkippedPackage` retry pass logic in `runWorkerForAccount` is unchanged
---
## Testing plan
The project has no automated tests, so verification is manual via Docker logs after deploy:
1. **Build cleanly:** `docker compose up -d --build worker` — no migration errors
2. **First cycle after deploy:** all channels scan (NULL `lastScannedAt`), all fields populated at end of cycle. Log lines confirm normal scan flow.
3. **Second cycle 5 min later:**
- Check logs for `"Skipping recently-scanned idle channel"` — should appear for any channel/topic that was empty last cycle
- Total `searchChatMessages` calls per cycle should drop dramatically (compare to first cycle)
4. **Failure-retry preservation:**
- Find a SkippedPackage with `attemptCount < cap`
- Run a cycle — confirm the channel/topic is NOT skipped (log says it's scanned)
- Confirm the SkippedPackage gets re-tried
5. **Backoff:**
- Pick a cold channel, wait for it to scan 5+ cycles cleanly
- Confirm `consecutiveEmptyScans` climbs to 5+
- Confirm subsequent cycles skip it (only scan every 5th)
6. **`getChat` short-circuit:**
- Pick an active channel
- Trigger an immediate cycle (UI button)
- If `last_message.id <= watermark`, expect log `"Channel caught up via getChat — skipping searchChatMessages"`
7. **Restart safety:**
- Push the change, restart worker
- First cycle after restart should log multiple "Skipping recently-scanned idle channel" lines (because the DB state survived)
- Total cycle time should be a fraction of a baseline restart
---
## Risks and mitigations
| Risk | Mitigation |
|---|---|
| Skip incorrectly applied → real failures never retried | Rule 1 (truly-idle includes `retryablePending === 0`) + dedicated test step 4 |
| `getChat` returns stale data | Next cycle's `getChat` corrects it; watermark guards correctness (no data loss) |
| `getForumTopicInfo` not available in TDLib 1.8.64 | Verify the method exists in the schema; fall back to scan if it throws |
| Backoff applies during legitimate activity bursts | Counter resets to 0 the moment any archive is found OR any retry is pending |
| Migration takes too long on the live DB | Both columns have NOT NULL defaults — Postgres can add them as fast metadata changes (no table rewrite) |
---
## What's explicitly NOT in this design
To keep scope tight:
- **Event-driven ingestion via `updateNewMessage`.** Bigger design, addressed separately. This design is compatible with it — when (D) lands, polling becomes a 4-hour safety net using these same skip rules.
- **Per-channel scan history UI.** Observability layer; separate design.
- **Surfacing the new counters in the admin dashboard.** Can come after the worker-side change is verified.
- **Backfilling `consecutiveEmptyScans` from historical `IngestionRun` data.** Not worth it — it'll converge to the correct value within ~6 cycles.
---
## Open questions
None — the failure-retry interaction was the main risk and is handled by Rule 1 + the existing retry pass.
@@ -0,0 +1,170 @@
# Send All From Creator — Design
**Date:** 2026-07-04
**Status:** Approved for planning
## Summary
Add a "Send all from [Creator]" button to the STL packages page that queues every
sendable package from a given creator for delivery to the user's Telegram. The
button appears in the packages-tab toolbar only when the list is filtered by a
single creator (`?creator=<name>`).
This reuses the existing single-send infrastructure (`BotSendRequest` +
`pg_notify('bot_send', id)` + the bot's `send-listener`) and closely mirrors the
existing `sendAllInGroupAction`. No bot, DB schema, or send-listener changes are
required.
## Context
Relevant existing pieces:
- **Creator field:** `Package.creator` is a nullable, indexed `String` on the
`Package` model (`prisma/schema.prisma:476-524`). Filtering by exact creator is
cheap.
- **Creator filter:** The packages list already supports `?creator=<name>`
(`src/app/(app)/stls/page.tsx:22,38`), rendered by the packages column as a
clickable value (`package-columns.tsx`).
- **Single send:** `send-to-telegram-button.tsx``POST /api/telegram/bot/send`
creates a `BotSendRequest` (status `PENDING`) and fires
`pg_notify('bot_send', requestId)`; the button then polls the request to
completion.
- **Existing bulk send (group):** `sendAllInGroupAction(groupId)` in
`src/app/(app)/stls/actions.ts:515-591` fetches the group's packages, filters to
those with `destChannelId` + `destMessageId`, skips any with an existing
`PENDING`/`SENDING` request for the same package + telegram link, creates a
`BotSendRequest` per remaining package, and fires `pg_notify` per package. It is
wired into the table via `handleSendAllInGroup` (`stl-table.tsx:264-278`) with a
`confirm()` dialog and a success toast — no polling.
- **Bot side:** `bot/src/send-listener.ts` processes every `BotSendRequest`
uniformly; nothing there needs to change.
## Requirements
1. When the packages tab is filtered by a single creator (`?creator=<name>` is
present), show a toolbar button labeled **"Send all from [Creator]"**.
2. When no creator filter is active, the button is not rendered.
3. Clicking the button shows a confirmation dialog before sending (consistent with
the single-send and group-send flows, since this pushes files to Telegram).
4. On confirm, queue **all** sendable packages from that creator across all pages —
not just the current page.
5. Skip packages that are not yet uploaded (missing `destChannelId` /
`destMessageId`) and packages that already have a `PENDING`/`SENDING` request for
the same package + telegram link (dedup).
6. Report real counts back to the user via toast, e.g.
"Queued 12 packages from [Creator]" and, when applicable, a skipped count.
7. No polling — the bulk action queues and returns; sends process in the background
via the bot listener (same behavior as group send).
## Design
### Server action — `sendAllFromCreatorAction(creatorName: string)`
New action in `src/app/(app)/stls/actions.ts`, mirroring `sendAllInGroupAction`:
1. Auth check (`auth()`), return `Unauthorized` if no session.
2. Load the user's `TelegramLink`; if none, return
"No linked Telegram account. Link one in Settings."
3. Reject an empty/blank `creatorName` (guard against sending the entire "no
creator" set — see Edge cases).
4. Fetch sendable packages directly with a `where` filter (no in-memory filtering):
```ts
prisma.package.findMany({
where: {
creator: creatorName,
destChannelId: { not: null },
destMessageId: { not: null },
},
select: { id: true },
})
```
5. If none, return "No uploaded packages found for this creator."
6. For each package, skip if an existing `PENDING`/`SENDING` `BotSendRequest`
exists for that package + telegram link; otherwise create a `BotSendRequest`
(status `PENDING`) and fire `pg_notify('bot_send', id)` (best-effort, wrapped in
try/catch — the bot also polls).
7. `revalidatePath("/stls")`.
8. Return counts so the UI can show them. This is a small improvement over
`sendAllInGroupAction`, which currently returns `{ success: true, data: undefined }`.
Shape:
```ts
{ success: true, data: { queued: number, skipped: number } }
```
where `skipped` counts packages that already had a live request. (Packages not
yet uploaded are excluded by the query and not counted as skipped.)
### UI — toolbar button in `stl-table.tsx`
- Derive the active creator from the URL: `const activeCreator =
searchParams.get("creator") ?? "";` (parallel to the existing
`activeTag` at `stl-table.tsx:445`).
- In the packages-tab toolbar (`stl-table.tsx:478` `flex flex-wrap items-center
gap-2` row), render the button only when `activeCreator` is non-empty:
```tsx
{activeCreator && (
<Button variant="outline" size="sm" className="h-9 gap-1.5"
onClick={handleSendAllFromCreator}>
<Send className="h-3.5 w-3.5" />
Send all from {activeCreator}
</Button>
)}
```
(Reuse the icon already used by the send action; place near the Upload / Group
buttons.)
- Add `handleSendAllFromCreator`, modeled on `handleSendAllInGroup`
(`stl-table.tsx:264-278`):
```ts
const handleSendAllFromCreator = useCallback(() => {
if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return;
startTransition(async () => {
const result = await sendAllFromCreatorAction(activeCreator);
if (result.success) {
const { queued, skipped } = result.data;
toast.success(
`Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` +
(skipped ? ` (${skipped} already queued)` : "")
);
router.refresh();
} else {
toast.error(result.error);
}
});
}, [activeCreator, router]);
```
- Import `sendAllFromCreatorAction` alongside the existing `sendAllInGroupAction`
import (`stl-table.tsx:51`).
### Data flow
```
User (filtered by creator) → clicks button → confirm dialog
→ sendAllFromCreatorAction(creatorName)
→ for each sendable, un-queued package: create BotSendRequest + pg_notify
→ returns { queued, skipped } → toast + router.refresh()
(background) bot send-listener consumes each BotSendRequest → delivers to user
```
## Edge cases
- **Blank creator:** The action rejects an empty/whitespace `creatorName` so a
stray `?creator=` never queues every package. The UI already gates the button on
a non-empty `activeCreator`, so this is defense-in-depth.
- **No linked Telegram account:** Returns an error toast, queues nothing.
- **No uploaded packages for creator:** Returns an error toast, queues nothing.
- **All packages already queued:** `queued: 0`, `skipped: N` → toast reflects it.
- **pg_notify failure:** Swallowed per-package (best-effort); the bot's periodic
poll picks the request up. Matches existing behavior.
## Out of scope / non-goals
- No per-row "send all from this creator" action (toolbar-only, per decision).
- No progress polling / live completion tracking for the bulk send.
- No changes to the bot, `send-listener`, `BotSendRequest` schema, or API routes.
- No batching/throttling beyond what the bot listener already does.
## Files touched
- `src/app/(app)/stls/actions.ts` — add `sendAllFromCreatorAction`.
- `src/app/(app)/stls/_components/stl-table.tsx` — derive `activeCreator`, add
`handleSendAllFromCreator`, render the toolbar button, import the new action.
@@ -0,0 +1,205 @@
# NAS Backup for Postgres + TDLib State — Design
**Date:** 2026-07-23
**Status:** Approved for planning
## Summary
Add a dedicated `backup` container to the DragonsStash stack that takes daily,
encrypted, deduplicated backups of the two things that can't be regenerated —
the Postgres database (inventory/STL metadata, users, everything the app
manages) and the two TDLib state volumes (Telegram session/auth state for the
worker and bot) — and ships them to a Synology NAS over NFS. STL archive
contents themselves are explicitly out of scope: they only live on this host
temporarily and are not backed up.
Backups are stored via [restic](https://restic.net/), which provides
encryption-at-rest, block-level dedup, and retention pruning natively, so no
custom encryption or pruning scripts need to be written or maintained.
## Context
Current state (as of this design):
- Production stack runs from `/opt/stacks/DragonsStash/docker-compose.yml` on
this Dockge-managed host, pulling prebuilt images from
`git.samagsteribbe.nl`. The `docker-compose.yml` in this repo is the
build/dev reference and should be kept in sync.
- Named volumes in use: `postgres_data` (Postgres 16 data directory),
`tdlib_state` (worker's TDLib session), `tdlib_bot_state` (bot's TDLib
session), `tmp_zips` and `manual_uploads` (both transient, explicitly out of
scope here).
- No backup mechanism, NFS mount, or host cron currently exists anywhere in
this deployment.
- The host already runs Uptime Kuma (used here for backup alerting) and Loki
(container logs are presumably already collected there).
## Requirements
1. Daily backup of the Postgres database and both TDLib state volumes.
2. Backups stored on a Synology NAS via NFS, not on local disk.
3. 14-day retention, oldest snapshots pruned automatically.
4. Backups encrypted at rest (Postgres dumps and TDLib session files both
contain sensitive material — password hashes, Telegram API secrets, live
session state).
5. Postgres backups must be transactionally consistent regardless of live app
traffic. TDLib state backups are best-effort (see Decisions below) — this
is an accepted trade-off, not a defect.
6. Alert (via existing Uptime Kuma) if a backup run fails or doesn't happen.
7. No new host-level state (no `/etc/fstab` entries, no host crontab) — the
backup mechanism should be a container, consistent with how everything
else on this host is deployed and versioned.
8. No new privileged access — specifically, the backup container must not
have Docker socket access or any ability to control sibling containers.
## Decisions
- **NFS mounted via Docker's native NFS volume driver** (`driver_opts: type:
nfs`), not a host-level mount. Keeps all backup-related state inside the
compose file instead of split across host config.
- **Restic, not hand-rolled tar+age+find.** Restic already solves encryption,
dedup, and retention correctly; hand-rolled scripts would be reinventing
that logic with more room for bugs.
- **TDLib state is tarred live (best-effort), not paused.** Pausing the
worker/bot for a clean snapshot would require mounting the Docker socket
into the backup container so it could stop/start sibling containers — a
real privilege escalation (a compromised backup container could then
control any container on the host). The downside of a best-effort tar is
bounded: worst case, a bad TDLib restore means redoing the Telegram SMS
auth flow, which is the same outcome as having no backup at all. That
bounded, low-severity downside doesn't justify the privilege escalation.
- **Fixed-time cron (`crond`), not a sleep-loop.** A `sleep 86400` loop drifts
on every container restart; a real crontab entry fires at a fixed time of
day regardless of restarts, for negligible extra complexity.
- **Restore is manual, not automated.** A script capable of restoring can
overwrite live state; that should always require a human deliberately
running it, not run unattended.
## Design
### New service: `backup`
Added to both `/opt/stacks/DragonsStash/docker-compose.yml` (production) and
this repo's `docker-compose.yml` (dev/build reference).
- **Image**: custom, `FROM alpine:3.20`, `apk add --no-cache restic
postgresql16-client curl tzdata dcron tar bash`. No dependency on app
source — independent Dockerfile, e.g. `backup/Dockerfile`.
- **Scheduling**: `crond -f` in the foreground as the container's entrypoint,
with a crontab installed at build time:
```
0 3 * * * /backup.sh >> /proc/1/fd/1 2>&1
0 4 * * 0 restic check >> /proc/1/fd/1 2>&1
```
(daily dump/backup at 03:00, weekly repo integrity check at 04:00 Sunday).
`restic init` runs once at container startup (entrypoint, before `crond`
starts), swallowing the "already initialized" error on subsequent
container (re)starts.
- **Network**: `internal` only — reaches `dragonsstash-db:5432` for
`pg_dump`. No ports exposed.
- **Volumes**:
- `tdlib_state:/data/tdlib-worker:ro`
- `tdlib_bot_state:/data/tdlib-bot:ro`
- `nas_backups:/backups`, a named volume defined with:
```yaml
nas_backups:
driver_opts:
type: nfs
o: "addr=${NAS_HOST},rw,nfsvers=4,soft,timeo=100"
device: ":${NAS_EXPORT_PATH}"
```
- **New `.env` entries**: `NAS_HOST`, `NAS_EXPORT_PATH` (NFS share details),
`RESTIC_PASSWORD` (repo encryption key), `KUMA_PUSH_URL` (Uptime Kuma push
monitor URL). All four are inputs to gather during implementation, not
hardcoded.
- `restart: unless-stopped`, no `privileged`, no Docker socket mount.
### `backup.sh`
```
set -euo pipefail
trap 'curl -fsS "$KUMA_PUSH_URL" --get --data-urlencode "status=down" \
--data-urlencode "msg=$BASH_COMMAND failed"' ERR
pg_dump -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-Fc -f /tmp/dragonsstash.dump
tar czf /tmp/tdlib.tar.gz -C /data tdlib-worker tdlib-bot
restic backup /tmp/dragonsstash.dump /tmp/tdlib.tar.gz
restic forget --keep-daily 14 --prune
rm -f /tmp/dragonsstash.dump /tmp/tdlib.tar.gz
curl -fsS "$KUMA_PUSH_URL" --get --data-urlencode "status=up" \
--data-urlencode "msg=OK"
```
`PGPASSWORD` and `RESTIC_REPOSITORY=/backups/restic-repo` are set as
container environment variables (from `.env`), not inline in the script.
### Data flow
```
crond (daily 03:00)
→ pg_dump (consistent snapshot via Postgres MVCC) → /tmp/dragonsstash.dump
→ tar tdlib_state + tdlib_bot_state (best-effort, live) → /tmp/tdlib.tar.gz
→ restic backup (encrypt + dedup) → NFS-mounted repo on Synology NAS
→ restic forget --keep-daily 14 --prune
→ curl Uptime Kuma push monitor (up on success, down + reason on any failure)
```
### Restore (manual, documented procedure — not scripted/automated)
```
restic -r /backups/restic-repo restore latest --target /tmp/restore
pg_restore -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
--clean --if-exists /tmp/restore/tmp/dragonsstash.dump
# untar /tmp/restore/tmp/tdlib.tar.gz back into the tdlib_state /
# tdlib_bot_state volumes (via a throwaway container mounting both)
```
## Alerting
- One Uptime Kuma **Push** monitor, created manually in the existing Kuma
instance, with an expected heartbeat interval of ~26 hours (slack past the
24h schedule so one slow run doesn't false-positive). Whatever notification
channels are already configured on that monitor fire automatically — no new
alerting integration.
- `backup.sh` pushes `status=up` on success and `status=down` (with the
failing command in `msg`) on any failure, via the `ERR` trap.
- Container logs go to stdout, collected the same way every other container's
logs already are on this host.
## Testing
This repo has no automated test framework (documented convention: manual
testing). For this infra change:
- After deploy: manually run `docker exec dragonsstash-backup /backup.sh`
once, confirm a snapshot appears (`restic snapshots`), confirm the Kuma
monitor goes green.
- **Restore drill** (once, during setup): actually restore the dump into a
scratch Postgres and untar the TDLib archive into scratch volumes, to prove
the backup is really restorable. Not automated or recurring for now.
## Out of scope / non-goals
- Backing up `tmp_zips` or `manual_uploads` — both transient by design.
- Automated/scheduled restore testing.
- Backing up any other stack on this host (this design is DragonsStash-only,
though the pattern — Docker-native NFS volume + restic — could be reused
for other stacks later).
- Pausing worker/bot for a guaranteed-consistent TDLib snapshot (see
Decisions).
## Files touched
- `docker-compose.yml` (this repo) and
`/opt/stacks/DragonsStash/docker-compose.yml` (production) — add `backup`
service, `nas_backups` volume.
- `backup/Dockerfile` — new.
- `backup/backup.sh` — new.
- `backup/crontab` — new.
- `.env.example` / `.env` — add `NAS_HOST`, `NAS_EXPORT_PATH`,
`RESTIC_PASSWORD`, `KUMA_PUSH_URL`.
@@ -0,0 +1,271 @@
# Provenance Backfill on Re-Index — Design
**Date:** 2026-07-23
**Status:** Approved for planning
## Summary
Recover the true origin of packages whose recorded "source" is a placeholder —
specifically the manual-upload and `rebuild.ts`-created records whose
`sourceChannelId` points at the destination (archive) channel itself. When a
real source channel is re-indexed and the worker encounters an archive that
matches such a placeholder package, it backfills the real
`sourceChannelId` / `sourceMessageId` / `sourceTopicId` / `sourceCaption` /
`creator` (and, for records that never had one, the file listing and preview)
onto the existing package — **without downloading the full archive**.
Matching is a two-stage process: a zero-download candidate lookup by
`fileName` + total `fileSize`, confirmed by a CRC32 fingerprint read from just
the archive's central directory via a **ranged (tail) download** of a few
KBMB, instead of the multi-GB whole file.
This is the first of four linked sub-projects. The others (creator
normalization, provenance display, missing-files) are out of scope here and get
their own spec → plan cycles. "Missing files" is explicitly deferred until this
lands.
## Context
Current state (as of this design):
- `Package` records their origin via `sourceChannelId`, `sourceMessageId`,
`sourceTopicId`, `sourceCaption`, and (for content dedup) `contentHash` and
`remoteUniqueId`. `creator` is a free-text string extracted at ingestion.
- Two ingestion paths create records with **placeholder provenance**, where the
"source" is the destination channel, not a real origin:
- **Manual uploads** (`worker/src/manual-upload.ts`) set
`sourceChannelId = destChannel.id` and `sourceMessageId = destResult.messageId`.
They *do* populate `PackageFile` (with `crc32`) from a local central-directory
read at upload time.
- **`rebuild.ts`** scans the destination channel and creates minimal records
with `fileCount == 0` and **no** `PackageFile` rows.
- The main worker upload path (`worker/src/worker.ts``uploadToChannel`) sends
archives to the destination channel with **no caption**, so provenance cannot
be recovered by re-reading destination messages — it has to come from matching
against real source channels.
- The scan/dedup ladder in `processOneArchiveSet` (`worker/src/worker.ts:1521`)
is entirely **source-channel-scoped**:
1. `findPackageByRemoteUniqueId(channel.id, …)` — same channel only
2. `packageExistsBySourceMessage(channel.id, …)` — same channel only
3. `findRepostedPackage(channel.id, fileName, size)` — same channel only
4. …then full download → `packageExistsByHash(contentHash)` — global, but only
*after* downloading the whole archive.
- Because a placeholder package's `sourceChannelId` is the destination, checks
13 never match it when a real source channel is scanned. Today the worker
therefore downloads the entire archive, hits check 4, finds it is a duplicate,
and skips — **wasting the download and leaving the wrong provenance in place.**
- There is already an in-production precedent for "match an already-known
archive during scan and enrich its metadata": when `findRepostedPackage`
matches, the worker backfills richer *topic* context onto the existing package
via `updatePackageTopicContext` (`worker/src/worker.ts:1608`+). Provenance
backfill is the same move, widened from same-channel to cross-channel.
- `remote.unique_id` is **not** a reliable cross-channel key: it identifies a
stored file object on Telegram's servers, not the content. The same archive
independently uploaded to two channels gets different `unique_id`s; it only
matches for forwarded messages (same underlying file object). This is why the
existing dedup scopes it to a single channel.
## Goals / Non-Goals
**Goals**
- Attribute true origin to placeholder-provenance packages during normal
re-index scans, opportunistically (no separate pass).
- Do it without downloading whole archives (ranged central-directory read only).
- Be non-destructive: only ever touch packages that currently have placeholder
provenance; never overwrite a real, non-placeholder source.
- Be idempotent: re-running a re-index does not re-mutate or duplicate.
**Non-Goals (separate sub-projects / deferred)**
- Creator name normalization / canonical creator entity.
- UI changes to display provenance.
- Recovering "missing files."
- Choosing a *preferred* origin when an archive genuinely exists in several
source channels (first confirmed source wins).
## Design
### 1. Candidate definition
> A package is a backfill candidate iff **`sourceChannelId == destChannelId`
> OR `sourceMessageId == 0`**.
Two placeholder shapes exist (verified against live data 2026-07-23):
- **Manual uploads** (`manual-upload.ts`): `sourceChannelId == destChannelId`,
real `contentHash`, real `sourceMessageId`, has a `PackageFile` listing.
- **Rebuild records** (`rebuild.ts`): `sourceMessageId == 0n` (deliberate
"unknown" sentinel), synthetic `contentHash = "rebuild:<destChannelId>:<destMessageId>"`,
`fileCount == 0`, and `sourceChannelId` set to an **arbitrary fallback source
channel** (`sourceChannels[0]`) — NOT the destination. (This is the common
case: e.g. 59,893 records after a destination rebuild.)
Normal ingestion always sets a real `sourceMessageId` (> 0) and a real source
channel, so neither marker matches a genuinely-sourced package. Both markers are
overwritten on backfill (source channel + message become real), so a record
stops being a candidate once fixed — this is what makes re-scans idempotent.
**Known limitation:** backfill does NOT rewrite a rebuild record's synthetic
`"rebuild:"` `contentHash` (the true content hash would require a full download,
which this feature avoids). That is acceptable — dedup after backfill relies on
`remoteUniqueId` + name/size within the source channel, not on `contentHash`.
### 2. Where it hooks
A new step is inserted into `processOneArchiveSet` **between check #3
(`findRepostedPackage`) and the full download**. It runs only after the
same-channel checks have missed (so genuine same-channel reposts keep their
existing fast paths).
Flow for the scanned archive set:
1. Stage A — **candidate lookup (zero download).** Query for a package where
`(sourceChannelId == destChannelId OR sourceMessageId == 0)` AND
`fileName == archiveName` AND `fileSize == totalArchiveSize`.
(`Package` has `@@index([fileName])`.)
- No candidate → fall through to normal ingestion unchanged.
2. Stage B — **fingerprint confirmation (tiny download).** See §3.
3. On confirmation → **backfill** (see §4) and return `null` (treated as a
duplicate; no full download, no new package). Increment a `zipsBackfilled`
counter.
4. On mismatch / failure / ambiguity → fall through to normal ingestion (see §6).
### 3. Fingerprint confirmation
The confirmation signal is the **multiset of internal CRC32s** of the archive's
entries (CRC32 of each entry's *uncompressed* data). This value is a property of
the archive contents and is identical regardless of which channel hosts the
file.
- **Candidate side:** for manual uploads, `PackageFile.crc32` is already
populated from the local central-directory read — zero cost. For **rebuild
records** (`fileCount == 0`, no `crc32`), there is nothing stored to compare
against; obtain the candidate's fingerprint with a **second ranged tail read
of its destination copy** (ZIP/7z — still no full download). RAR candidates
cannot be tail-read on either side, so they fall back to name+size (see §5).
- **Scanned-source side:** read via a **ranged (tail) download** of the central
directory:
- **ZIP** (incl. multipart): the End-of-Central-Directory record + central
directory live at the tail of the last part. Download only that tail and
parse entries. (Reuses the lightweight-listing mechanism that is
sub-project 4's core.)
- **7z**: a start header at byte 0 points to an end header at the tail; fetch
both small pieces.
- **RAR**: headers are scattered through the file — no cheap tail read. See §5.
- **Match rule:** confirmed iff the sorted CRC32 multisets are equal **and** file
counts are equal.
The comparison logic (CRC32 multiset equality) and the candidate-match predicate
are implemented as **pure functions** with no TDLib dependency, so they are unit
testable (see §7).
### 4. What gets written
On a confirmed match, update the candidate package in a single transaction,
overwriting only placeholder/empty fields:
| Field | New value | Condition |
|---|---|---|
| `sourceChannelId` | scanned `channel.id` | always |
| `sourceMessageId` | scanned `parts[0].id` | always |
| `sourceTopicId` | `ctx.sourceTopicId` | always |
| `sourceCaption` | scanned message caption | always |
| `remoteUniqueId` | scanned `firstRemoteUniqueId` | always (enables future same-channel dedup via check #1) |
| `creator` | re-derived from source (topic > filename > channel) | **always** — un-normalized; the later creator-normalization sub-project cleans it up |
| `fileCount` + `PackageFile[]` | from the central-directory read | only if candidate had none (`fileCount == 0`) — folds in the sub-project 4 outcome for rebuild records |
| `previewData` / `previewMsgId` | matched preview from scan | only if candidate has none |
**Left untouched:** `contentHash`, `destChannelId`, `destMessageId`,
`destMessageIds` — the bytes physically live in the destination channel; that is
correct and must not change.
**Transaction safety:** re-check `sourceChannelId == destChannelId` *inside* the
transaction before writing, so a concurrent worker that already backfilled the
record causes this one to no-op (mirrors the existing `backfill.ts` guard).
### 5. RAR handling
RAR sources cannot be tail-read, so no cheap CRC fingerprint is available.
Decision: **backfill RAR matches on `fileName` + `fileSize` alone**, flagged as
lower-confidence in logs and via a `SystemNotification`, so they can be audited.
No full download.
This name+size-only fallback applies to any candidate that cannot produce a
CRC32 fingerprint cheaply: a RAR **source**, a RAR **candidate**, or a rebuild
candidate whose destination copy is RAR. ZIP/7z rebuild candidates are still
confirmed by fingerprint via the second tail read described in §3.
### 6. Conflict, mismatch, ambiguity
- **Fingerprint mismatch** → the scanned archive is genuinely different content
that merely shares name+size. Fall through to **normal ingestion** (download +
index) — it is a new package for this source.
- **Ambiguous candidates** (2+ match name+size and the fingerprint cannot
disambiguate) → log a `SystemNotification`, backfill nothing, and fall through
to normal ingestion; the post-download `packageExistsByHash` check still
dedups it safely.
- **Same archive in multiple source channels** → the **first re-indexed source
that confirms wins.** After backfill the package has a real source, so it is no
longer a candidate; later scans treat it as an ordinary duplicate via checks #1
(the `remoteUniqueId` we set) or #3.
### 7. Idempotency
Falls out of the candidate definition. Once backfilled:
- `sourceChannelId` is the real channel → no longer a candidate.
- A re-scan of that source hits check #1 (`remoteUniqueId`, now set) or check #3
(`findRepostedPackage`) → normal dedup skip. No re-mutation, no duplicate.
### 8. Error handling
- **Tail-download failure** (network / `FLOOD_WAIT`) → cannot confirm this round.
Do **not** fall back to a full download and do **not** guess: leave the
candidate untouched and let the next re-index retry. Wrap the ranged read in
`withFloodWait` (per the TDLib skill).
- All new TDLib calls follow the skill's patterns: `withFloodWait`, listener
attached before the async op, client closed in `finally`.
### 9. Visibility
- Add a `zipsBackfilled` counter to `IngestionRun` activity so a re-index run
reports "N provenance backfills" instead of silently mutating records.
- Info log per backfill (candidate id, old vs new source, confidence:
fingerprint | name+size-RAR).
- `SystemNotification` for ambiguous-candidate cases.
## Testing
The repo currently has no test framework (`CLAUDE.md`: "testing is manual"). This
work introduces a **lightweight test harness** for the worker (e.g. `vitest` or
node's built-in `node:test`) and unit-tests the correctness-critical pure logic:
- **Unit (automated):**
- CRC32 multiset fingerprint equality (equal sets, different order, differing
counts, disjoint sets).
- Candidate-match predicate (name+size+placeholder true/false cases).
- Field-merge rules (which fields overwrite, which only fill-if-empty).
- **Manual integration checklist:**
1. Re-index a real source channel containing a known manually-uploaded pack →
verify `sourceChannelId`/`sourceMessageId`/`sourceCaption`/`creator` are
backfilled and no full download occurs.
2. A rebuild-created record (`fileCount == 0`) → verify listing + provenance
are both populated from the single tail read.
3. A RAR pack → verify name+size backfill with the lower-confidence log/notice.
4. Re-run the same re-index → verify it is a no-op (idempotent).
5. A genuine name+size collision (different content) → verify it ingests as a
new package rather than being mis-attributed.
## Open Questions
None blocking. Preferred-origin selection among multiple real sources is
deliberately out of scope (first confirmed wins).
## Affected Code (indicative, for planning)
- `worker/src/worker.ts``processOneArchiveSet`: new Stage A/B step; new counter.
- `worker/src/archive/` — ranged central-directory reader (ZIP/7z tail); shared
with sub-project 4. Pure CRC32-fingerprint compare helper.
- `worker/src/tdlib/download.ts` — ranged/partial download support (`offset`/`limit`).
- `worker/src/db/queries.ts` — candidate lookup + transactional backfill update.
- `prisma/schema.prisma``IngestionRun.zipsBackfilled` (and run-counter plumbing).
- Worker test harness + first unit tests.
@@ -0,0 +1,180 @@
# Ranged inner-file listing for RAR & 7z — design
**Date:** 2026-07-27
**Status:** Approved (design), pending spec review → implementation plan
## Problem
The reindex/provenance-backfill path (`worker/src/provenance-backfill.ts`) can index an
archive's inner files *without* re-downloading it, by reading the file listing from a small
ranged read of the copy already in the source/destination channel. This works **only for
ZIP** today (ZIP keeps its central directory in a tail that `parseZipCentralDirectoryFromTail`
reads). For **RAR and 7z**, `tryProvenanceBackfill` backfills provenance (creator, source
channel, `remoteUniqueId`) so the file is skipped on re-scan and never re-downloaded — but it
leaves the inner listing empty (`fileCount = 0`), because `scannedEntries` is only computed for
ZIP.
Scope of the gap (rebuild placeholders with `fileCount = 0`, as of 2026-07-27):
| Type | Count | Total | Avg | Max | Multipart |
|---|---|---|---|---|---|
| 7z | 19,646 | 14 TB | 0.73 GB | 3.9 GB | 0 |
| RAR | 12,780 | 13 TB | 1.04 GB | 116 GB | 1,016 |
A "just download the whole archive" fallback for all of these means ~27 TB of re-downloads —
the exact cost this path exists to avoid.
## Goals
- Index the inner files (names + sizes; CRCs where cheaply available) of RAR and 7z
placeholders **without** downloading the whole archive in the common case.
- Reuse the existing, battle-tested CLI listing parsers (`parse7zOutput`,
`parseUnrarTechnical`) rather than reimplementing filename/size/CRC extraction.
- Keep cost proportional to **file count**, not archive size (so even the 116 GB RAR is cheap).
- Guarantee a listing for the rare archives the cheap path can't handle, via a full-download
fallback that respects the existing max-size guard.
## Non-goals
- No change to ingestion of genuinely-new files (those are downloaded in full to be re-uploaded
regardless, so a cheap listing does not help there). This feature only affects the
provenance-backfill / skip path.
- No new ZIP behaviour — the existing ZIP tail reader stays as-is.
- Not attempting to list password-encrypted-header archives from ranged reads (no password);
those take the fallback and, if oversized, are flagged.
## Approach (chosen: "harvest header regions → sparse file → native CLI")
Do the *minimum* binary parsing needed to locate an archive's header bytes, fetch only those
via ranged reads, write them into a sparse temp file at their true offsets (data regions left
as unwritten zero holes → ~no disk use), then run the real `7z l` / `unrar lt` and reuse the
existing parsers. The native tools do the hard parsing (7z's LZMA-encoded headers, RAR's two
format versions, Unicode names) — we only compute where the headers are.
Rejected alternatives: full native TS parsers (most custom binary code, highest risk);
RAR5-quick-open-only (most community RARs lack it → collapses to ~13 TB of RAR downloads).
## Components
New directory `worker/src/archive/ranged/`, one focused module per concern, all returning the
existing `FileEntry[]` type from `zip-reader.ts`:
- `sparse-list.ts``listFromSparse(parts, runner, parse) → FileEntry[] | null`, where each
`part` is `{ fileName, size, regions: {offset, bytes}[] }`. For each part it writes a sparse
temp file (`truncate` to `size`, then write only the header `regions` at their offsets),
co-locates all parts in one temp dir under their real names, invokes the supplied CLI runner
(`7z l` / `unrar lt`) on the first part, feeds stdout to the supplied `parse` fn
(`parse7zOutput` / `parseUnrarTechnical`), and cleans up. Single-part archives are just the
one-element case. Returns `null` on CLI error / empty parse.
- `sevenz-ranged.ts``readSevenZListingRanged(client, parts) → FileEntry[] | null`.
- `rar-ranged.ts``readRarListingRanged(client, parts) → FileEntry[] | null`.
- Dispatcher in `provenance-backfill.ts`: `readScannedListingRanged(archiveType, client, parts)`
replacing the current `if (archiveType === "ZIP")` branch; the destination-copy read in
`resolveCandidateFingerprintEntries` gets the same dispatch.
`FileEntry` shape (unchanged): `{ path, fileName, extension, compressedSize, uncompressedSize, crc32 }`.
### 7z ranged listing
7z layout: 32-byte signature header at offset 0 → packed streams → end header (lists files) at
the end; the signature header stores the end header's location.
1. Ranged-read `[0, 32)`; validate magic `37 7A BC AF 27 1C`. Read LE `uint64`
`NextHeaderOffset` (byte 12) and `NextHeaderSize` (byte 20). End header is at absolute offset
`32 + NextHeaderOffset`, length `NextHeaderSize`.
2. Ranged-read `[32 + NextHeaderOffset, NextHeaderSize)` — the "next header".
3. Branch on the next header's first byte (a 7z property id):
- **`0x01` (kHeader, plain/uncompressed header):** two regions suffice —
`{0: sigHeader}` and `{32+NextHeaderOffset: endHeader}`.
- **`0x17` (kEncodedHeader, LZMA-compressed header):** the next header is only a *descriptor*
whose `PackInfo` points at a packed header stream stored **in the middle** of the file (not
at EOF). Parse the descriptor's `StreamsInfo → kPackInfo (0x06)` to read `PackPos` and the
`PackSize`s (7z variable-length "numbers"; sum them). Ranged-read the contiguous packed
region `[32 + PackPos, Σ PackSize)` and add it as a **third** sparse region. `7z l` then
decodes the header from that region.
- **anything else:** return `null` (→ fallback).
4. `listFromSparse` with the 2 or 3 regions, runner = `7z l`. `parse7zOutput` yields
names+sizes (`crc32: null`, as today). Return `null` on bad magic / read failure / CLI error.
**Why the third region is required (spike finding, 2026-07-27):** the original two-region
(start+end) reconstruction was proven insufficient in a live test — `7z l` rejected it with
"Cannot open the file as [7z] archive" because these archives use an *encoded* header whose
compressed bytes live in a packed stream in the file body (a sparse hole), not at EOF. The
`0x17` branch fetches exactly that packed region. `7z l` still never touches the file-data
packed streams (it only lists), so those gaps stay sparse. The `read7zNumber` reader (7z's
base-128-ish variable-length integer with a first-byte length mask) and the minimal
`kPackInfo` walk are the only new 7z binary parsing; `7z l` still does the actual file listing.
All 7z placeholders are single-part.
### RAR ranged listing
RAR has no index; walk the block chain, parsing only each block's **size fields** to step
forward and harvest header bytes.
1. Read first ~16 bytes; detect **RAR4** (`52 61 72 21 1A 07 00`) vs **RAR5** (`…07 01 00`) and
the signature length.
2. From just after the signature, loop:
- Ranged-read a header chunk (start 8 KB; if parsed `HeaderSize` exceeds it — long filenames
— re-read exactly).
- Minimal block-extent parse:
- RAR5: `CRC32(4)` + vint `HeaderSize` + vint `HeaderType` + vint `HeaderFlags`; if the
"extra area" flag (`0x0001`) → vint `ExtraAreaSize`; if the "data present" flag
(`0x0002`) → vint `DataSize`. Next block = `pos + 4 + len(HeaderSize vint) + HeaderSize
+ DataSize`.
- RAR4: `HEAD_CRC(2)` + `HEAD_TYPE(1)` + `HEAD_FLAGS(2)` + `HEAD_SIZE(2)`; if flag `0x8000`
`ADD_SIZE(4)`. Next block = `pos + HEAD_SIZE + ADD_SIZE`.
- Harvest `[blockOffset, blockOffset + HeaderSize)` into the regions list.
- Stop at the end-of-archive block or EOF.
3. `listFromSparse` (headers present, data sparse) → `unrar lt``parseUnrarTechnical`. RAR
headers carry CRC32, so RAR contributes CRCs (fingerprint disambiguation keeps working).
**Multipart RAR** (1,016): each volume starts with its own signature + headers. Walk **each
part from its own signature**, reconstruct one sparse temp file per part with correct names
(`name.part1.rar`, `.part2.rar`, …) co-located in a temp dir, and run `unrar lt` on part 1 —
`unrar` auto-discovers co-located siblings (per the existing reader's note). The global-offset →
`(part, offsetInPart)` mapping reuses the multipart size math the ZIP path already uses.
## Fallback & integration
- A ranged reader returning `null` = cheap read failed (bad magic, read error, walk gave up, or
CLI error on the sparse file) → **full-download fallback**: download the whole archive, run the
existing `readRarContents` / `read7zContents`, backfill.
- The fallback is gated by `config.maxZipSizeMB` (the same guard used at ingest). Over the cap →
no download; write a `SystemNotification` (`INTEGRITY_AUDIT`, WARNING) and leave the listing
empty for manual review. This ensures nothing pathological (e.g. the 116 GB RAR) is pulled.
- Downstream is unchanged: `compareFingerprints` already treats null/incomplete CRCs as
"incomplete" (name-size path), and `backfillProvenance` writes entries when the candidate's
`fileCount === 0`.
- Observability: reuse the `zipsBackfilled` counter; add structured logs with
`confidence: "ranged" | "full-download-fallback"` and a WARN on fallback so miss-rate is
visible.
## Risks & de-risking spike (do before the full build)
On 34 real placeholder archives per format:
1. Confirm `downloadFileRange` returns correct bytes at **arbitrary (non-tail) offsets**
currently only tail-verified in production. Underpins everything; if it fails, stop and
rethink. (Note: `range-download.ts` flags absolute-offset behaviour as pending live
verification; tail reads are proven by the 43 ZIP backfills done 2026-07-26.)
2. Confirm `7z l` and `unrar lt` list correctly from a **sparse reconstructed file** — single
part first, then multipart RAR (the highest-risk case).
If multipart-RAR sparse reconstruction proves unreliable in the spike, multipart RAR uses the
full-download fallback (respecting the size cap → oversized ones flagged, not downloaded).
## Testing
- **Unit (vitest, alongside `central-directory.test.ts`):** 7z signature-header parse; RAR4 &
RAR5 block-extent walk against committed small fixtures; `sparse-list` writes the correct
regions. Pure logic, no TDLib.
- **Live post-deploy:** watch `zipsBackfilled` climb for RAR/7z via the ranged path; spot-check
a handful of backfilled packages' `package_files` against a real `unrar lt` / `7z l` on a full
download of the same file; confirm the fallback/flag path fires on a deliberately-broken case.
## Rollout
Local build + deploy (no GitHub push required), per the established recipe: build
`worker/Dockerfile` locally, recreate the `dragonsstash-worker` container from the local image
(no `pull`). No new DB migration. The scheduler re-runs hourly and will backfill RAR/7z
placeholders on subsequent cycles.
@@ -0,0 +1,11 @@
-- AlterTable: per-channel scan-state columns
ALTER TABLE "account_channel_map"
ADD COLUMN "lastScannedAt" TIMESTAMP(3),
ADD COLUMN "lastScanFoundArchives" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consecutiveEmptyScans" INTEGER NOT NULL DEFAULT 0;
-- AlterTable: per-topic scan-state columns (forum channels)
ALTER TABLE "topic_progress"
ADD COLUMN "lastScannedAt" TIMESTAMP(3),
ADD COLUMN "lastScanFoundArchives" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consecutiveEmptyScans" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,5 @@
-- AlterTable: per-topic user-controlled fetch toggle (forum channels)
-- Additive, safe default (true) so existing rows backfill to "enabled" and
-- current behaviour is unchanged. Disabling is non-destructive.
ALTER TABLE "topic_progress"
ADD COLUMN "fetchEnabled" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,6 @@
-- AlterTable: track the forum topic currently being processed on the live run
-- so the worker status panel can offer a "skip & disable topic" action.
-- Additive, nullable — no data change for existing rows.
ALTER TABLE "ingestion_runs"
ADD COLUMN "currentTopicId" BIGINT,
ADD COLUMN "currentAccountChannelMapId" TEXT;
@@ -0,0 +1,5 @@
-- AlterTable: count of packages whose provenance was backfilled during a run
-- (opportunistic cross-channel provenance backfill). Additive, non-null with a
-- default of 0 — no data change for existing rows.
ALTER TABLE "ingestion_runs"
ADD COLUMN "zipsBackfilled" INTEGER NOT NULL DEFAULT 0;
+27
View File
@@ -450,6 +450,17 @@ model AccountChannelMap {
channelId String channelId String
role ChannelRole @default(READER) role ChannelRole @default(READER)
lastProcessedMessageId BigInt? lastProcessedMessageId BigInt?
/// When this channel was last scanned (any reason, including skipped scans
/// that bumped the timestamp). Used by the recency-skip guard.
lastScannedAt DateTime?
/// True if the last scan found archives OR left retryable SkippedPackages
/// pending. Tracks "this channel has work I might need to revisit" — not
/// just "I uploaded something this cycle".
lastScanFoundArchives Boolean @default(false)
/// Number of consecutive cycles where this channel was trulyIdle (no
/// archives, no failures, no retryables). Drives the backoff that lets
/// cold channels skip cycles entirely.
consecutiveEmptyScans Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@ -558,6 +569,7 @@ model IngestionRun {
zipsFound Int @default(0) zipsFound Int @default(0)
zipsDuplicate Int @default(0) zipsDuplicate Int @default(0)
zipsIngested Int @default(0) zipsIngested Int @default(0)
zipsBackfilled Int @default(0)
errorMessage String? errorMessage String?
// Live activity tracking — written by worker in real-time // Live activity tracking — written by worker in real-time
@@ -571,6 +583,8 @@ model IngestionRun {
totalBytes BigInt? // Total size of current download totalBytes BigInt? // Total size of current download
downloadPercent Int? // 0-100 downloadPercent Int? // 0-100
lastActivityAt DateTime? // When activity was last updated lastActivityAt DateTime? // When activity was last updated
currentTopicId BigInt? // Forum topic currently being processed (live "skip topic")
currentAccountChannelMapId String? // AccountChannelMap owning the topic being processed
account TelegramAccount @relation(fields: [accountId], references: [id]) account TelegramAccount @relation(fields: [accountId], references: [id])
packages Package[] packages Package[]
@@ -586,7 +600,20 @@ model TopicProgress {
accountChannelMapId String accountChannelMapId String
topicId BigInt topicId BigInt
topicName String? topicName String?
/// User-controlled fetch toggle. When false, the worker skips this topic
/// (no scanning, no fetching/transfer). Defaults true so every existing and
/// newly-discovered topic is fetched unless explicitly disabled. Disabling
/// is non-destructive — already-fetched packages are kept.
fetchEnabled Boolean @default(true)
lastProcessedMessageId BigInt? lastProcessedMessageId BigInt?
/// When this topic was last scanned (any reason). Used by recency-skip.
lastScannedAt DateTime?
/// True if the last scan found archives OR has retryable SkippedPackages
/// pending for this topic. See AccountChannelMap doc for details.
lastScanFoundArchives Boolean @default(false)
/// Number of consecutive cycles where this topic was trulyIdle. Drives
/// backoff for cold topics.
consecutiveEmptyScans Int @default(0)
accountChannelMap AccountChannelMap @relation(fields: [accountChannelMapId], references: [id], onDelete: Cascade) accountChannelMap AccountChannelMap @relation(fields: [accountChannelMapId], references: [id], onDelete: Cascade)
+17 -1
View File
@@ -1,7 +1,23 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { Sidebar } from "@/components/layout/sidebar"; import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
export default function AppLayout({ children }: { children: React.ReactNode }) { export default async function AppLayout({ children }: { children: React.ReactNode }) {
// Guard against a stale JWT session whose user no longer exists in the
// database (e.g. after a DB reset). The signed cookie still passes edge
// middleware, but every downstream query keyed on session.user.id would fail.
// Send such sessions to /logout, which clears the cookie and returns to login.
const session = await auth();
if (session?.user?.id) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true },
});
if (!user) redirect("/logout");
}
return ( return (
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
<div className="hidden lg:block"> <div className="hidden lg:block">
@@ -7,6 +7,7 @@ import {
Check, Check,
AlertCircle, AlertCircle,
ImageOff, ImageOff,
Maximize2,
} from "lucide-react"; } from "lucide-react";
import { import {
Dialog, Dialog,
@@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { setPreviewFromExtract } from "../actions"; import { setPreviewFromExtract } from "../actions";
import { ImageLightbox } from "./image-lightbox";
interface ArchiveImage { interface ArchiveImage {
id: string; id: string;
@@ -65,6 +67,7 @@ export function ArchivePreviewPicker({
const [thumbnails, setThumbnails] = useState<Map<string, ThumbnailState>>(new Map()); const [thumbnails, setThumbnails] = useState<Map<string, ThumbnailState>>(new Map());
const [selectedPath, setSelectedPath] = useState<string | null>(null); const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map()); const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
// Track which paths have already been requested to avoid re-requesting // Track which paths have already been requested to avoid re-requesting
const requestedPaths = useRef<Set<string>>(new Set()); const requestedPaths = useRef<Set<string>>(new Set());
@@ -290,71 +293,85 @@ export function ArchivePreviewPicker({
const isFailed = thumbState?.status === "failed"; const isFailed = thumbState?.status === "failed";
return ( return (
<button <div key={img.id} className="group relative">
key={img.id} {isLoaded && thumbState?.imageUrl && (
type="button" <button
className={cn( type="button"
"relative aspect-square rounded-lg overflow-hidden border-2 transition-all", className="absolute top-1.5 left-1.5 z-10 flex h-6 w-6 items-center justify-center rounded-md bg-black/60 text-white opacity-0 transition-opacity hover:bg-black/80 group-hover:opacity-100"
"hover:border-primary/50 cursor-pointer group", onClick={(e) => {
isSelected e.stopPropagation();
? "border-primary ring-2 ring-primary/30" setLightboxSrc(thumbState.imageUrl!);
: "border-border", }}
isFailed && "opacity-60" title="Enlarge"
)} >
onClick={() => { <Maximize2 className="h-3.5 w-3.5" />
if (isLoaded) { </button>
setSelectedPath(img.path);
} else if (isFailed) {
// Allow retry on failed
requestedPaths.current.delete(img.path);
requestThumbnail(img.path);
} else if (!thumbState || thumbState.status === "idle") {
requestThumbnail(img.path);
}
}}
title={img.path}
>
{isLoaded && thumbState.imageUrl ? (
<img
src={thumbState.imageUrl}
alt={img.fileName}
className="h-full w-full object-cover"
loading="lazy"
/>
) : isLoading ? (
<div className="h-full w-full flex items-center justify-center bg-muted">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : isFailed ? (
<div className="h-full w-full flex flex-col items-center justify-center bg-muted gap-1">
<AlertCircle className="h-4 w-4 text-destructive" />
<span className="text-[10px] text-destructive px-1 text-center">
Click to retry
</span>
</div>
) : (
<div className="h-full w-full flex items-center justify-center bg-muted">
<ImageIcon className="h-5 w-5 text-muted-foreground" />
</div>
)} )}
<button
type="button"
className={cn(
"relative aspect-square w-full rounded-lg overflow-hidden border-2 transition-all",
"hover:border-primary/50 cursor-pointer",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-border",
isFailed && "opacity-60"
)}
onClick={() => {
if (isLoaded) {
setSelectedPath(img.path);
} else if (isFailed) {
// Allow retry on failed
requestedPaths.current.delete(img.path);
requestThumbnail(img.path);
} else if (!thumbState || thumbState.status === "idle") {
requestThumbnail(img.path);
}
}}
title={img.path}
>
{isLoaded && thumbState.imageUrl ? (
<img
src={thumbState.imageUrl}
alt={img.fileName}
className="h-full w-full object-cover"
loading="lazy"
/>
) : isLoading ? (
<div className="h-full w-full flex items-center justify-center bg-muted">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : isFailed ? (
<div className="h-full w-full flex flex-col items-center justify-center bg-muted gap-1">
<AlertCircle className="h-4 w-4 text-destructive" />
<span className="text-[10px] text-destructive px-1 text-center">
Click to retry
</span>
</div>
) : (
<div className="h-full w-full flex items-center justify-center bg-muted">
<ImageIcon className="h-5 w-5 text-muted-foreground" />
</div>
)}
{/* Selection checkmark */} {/* Selection checkmark */}
{isSelected && ( {isSelected && (
<div className="absolute top-1.5 right-1.5 h-5 w-5 rounded-full bg-primary flex items-center justify-center"> <div className="absolute top-1.5 right-1.5 h-5 w-5 rounded-full bg-primary flex items-center justify-center">
<Check className="h-3 w-3 text-primary-foreground" /> <Check className="h-3 w-3 text-primary-foreground" />
</div> </div>
)} )}
{/* File info overlay */} {/* File info overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate"> <p className="text-[10px] text-white truncate">
{img.fileName} {img.fileName}
</p> </p>
<p className="text-[9px] text-white/70"> <p className="text-[9px] text-white/70">
{formatBytes(img.size)} {formatBytes(img.size)}
</p> </p>
</div> </div>
</button> </button>
</div>
); );
})} })}
</div> </div>
@@ -394,6 +411,13 @@ export function ArchivePreviewPicker({
</div> </div>
)} )}
</DialogContent> </DialogContent>
<ImageLightbox
src={lightboxSrc}
open={!!lightboxSrc}
onOpenChange={(open) => {
if (!open) setLightboxSrc(null);
}}
/>
</Dialog> </Dialog>
); );
} }
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface CreatorFilterProps {
creators: string[];
value: string; // active creator, "" when none
onChange: (creator: string) => void; // "" clears the filter
}
export function CreatorFilter({ creators, value, onChange }: CreatorFilterProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className="h-9 w-[200px] justify-between"
>
<span className="truncate">{value || "All Creators"}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="Search creators..." className="h-9" />
<CommandList>
<CommandEmpty>No creators found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__all__"
onSelect={() => {
onChange("");
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")}
/>
All Creators
</CommandItem>
{creators.map((creator) => (
<CommandItem
key={creator}
value={creator}
onSelect={() => {
onChange(creator);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === creator ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{creator}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,41 @@
"use client";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
interface ImageLightboxProps {
src: string | null;
alt?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Full-size, in-page preview viewer. Renders the image at native size capped
* to the viewport (object-contain). Dismiss via the close button, Esc, or by
* clicking the overlay.
*/
export function ImageLightbox({
src,
alt = "",
open,
onOpenChange,
}: ImageLightboxProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-auto max-w-[95vw] border-0 bg-transparent p-0 shadow-none sm:max-w-[90vw]">
<DialogTitle className="sr-only">Enlarged preview image</DialogTitle>
{src && (
<img
src={src}
alt={alt}
className="mx-auto max-h-[88vh] w-auto max-w-full rounded-lg object-contain"
/>
)}
</DialogContent>
</Dialog>
);
}
@@ -1,12 +1,14 @@
"use client"; "use client";
import { useState } from "react";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge } from "lucide-react"; import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge, Maximize2 } from "lucide-react";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header"; import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { SendToTelegramButton } from "./send-to-telegram-button"; import { SendToTelegramButton } from "./send-to-telegram-button";
import { ImageLightbox } from "./image-lightbox";
export interface PackageRow { export interface PackageRow {
id: string; id: string;
@@ -84,14 +86,30 @@ export function formatBytes(bytesStr: string): string {
} }
function PreviewCell({ pkg }: { pkg: PackageRow }) { function PreviewCell({ pkg }: { pkg: PackageRow }) {
const [lightboxOpen, setLightboxOpen] = useState(false);
if (pkg.hasPreview) { if (pkg.hasPreview) {
const src = `/api/zips/${pkg.id}/preview`;
return ( return (
<img <>
src={`/api/zips/${pkg.id}/preview`} <button
alt="" type="button"
className="h-9 w-9 rounded-md object-cover bg-muted" className="group/preview relative h-9 w-9 overflow-hidden rounded-md bg-muted"
loading="lazy" onClick={() => setLightboxOpen(true)}
/> title="Click to enlarge"
>
<img
src={src}
alt=""
className="h-9 w-9 object-cover"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-black/50 opacity-0 transition-opacity group-hover/preview:opacity-100">
<Maximize2 className="h-3.5 w-3.5 text-white" />
</div>
</button>
<ImageLightbox src={src} open={lightboxOpen} onOpenChange={setLightboxOpen} />
</>
); );
} }
return ( return (
@@ -13,6 +13,7 @@ import {
Upload, Upload,
ImagePlus, ImagePlus,
Images, Images,
Maximize2,
} from "lucide-react"; } from "lucide-react";
import { import {
Dialog, Dialog,
@@ -30,6 +31,7 @@ import type { PackageRow } from "./package-columns";
import { SendToTelegramButton } from "./send-to-telegram-button"; import { SendToTelegramButton } from "./send-to-telegram-button";
import { uploadPackagePreview } from "../actions"; import { uploadPackagePreview } from "../actions";
import { ArchivePreviewPicker } from "./archive-preview-picker"; import { ArchivePreviewPicker } from "./archive-preview-picker";
import { ImageLightbox } from "./image-lightbox";
interface FileItem { interface FileItem {
id: string; id: string;
@@ -264,6 +266,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [showPreviewPicker, setShowPreviewPicker] = useState(false); const [showPreviewPicker, setShowPreviewPicker] = useState(false);
const [previewLightboxOpen, setPreviewLightboxOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handlePreviewUpload = useCallback( const handlePreviewUpload = useCallback(
@@ -384,9 +387,8 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
<button <button
type="button" type="button"
className="relative group h-20 w-20 shrink-0 rounded-lg overflow-hidden bg-muted" className="relative group h-20 w-20 shrink-0 rounded-lg overflow-hidden bg-muted"
onClick={() => fileInputRef.current?.click()} onClick={() => setPreviewLightboxOpen(true)}
disabled={uploading} title="Click to enlarge"
title="Click to replace preview image"
> >
<img <img
src={localPreviewUrl ?? `/api/zips/${pkg!.id}/preview`} src={localPreviewUrl ?? `/api/zips/${pkg!.id}/preview`}
@@ -394,11 +396,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{uploading ? ( <Maximize2 className="h-5 w-5 text-white" />
<Loader2 className="h-5 w-5 text-white animate-spin" />
) : (
<Upload className="h-5 w-5 text-white" />
)}
</div> </div>
</button> </button>
) : ( ) : (
@@ -582,6 +580,11 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
}} }}
/> />
)} )}
<ImageLightbox
src={pkg ? (localPreviewUrl ?? `/api/zips/${pkg.id}/preview`) : null}
open={previewLightboxOpen}
onOpenChange={setPreviewLightboxOpen}
/>
</> </>
); );
} }
+58 -9
View File
@@ -3,7 +3,7 @@
import { useState, useCallback, useTransition, useMemo, useRef } from "react"; import { useState, useCallback, useTransition, useMemo, useRef } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { Search, Layers, Upload } from "lucide-react"; import { Search, Layers, Upload, Send } from "lucide-react";
import { UploadDialog } from "./upload-dialog"; import { UploadDialog } from "./upload-dialog";
import { useDataTable } from "@/hooks/use-data-table"; import { useDataTable } from "@/hooks/use-data-table";
import { import {
@@ -16,6 +16,7 @@ import {
import { PackageFilesDrawer } from "./package-files-drawer"; import { PackageFilesDrawer } from "./package-files-drawer";
import { IngestionStatus } from "./ingestion-status"; import { IngestionStatus } from "./ingestion-status";
import { SkippedPackagesTab } from "./skipped-packages-tab"; import { SkippedPackagesTab } from "./skipped-packages-tab";
import { CreatorFilter } from "./creator-filter";
import { DataTable } from "@/components/shared/data-table"; import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination"; import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options"; import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
@@ -49,6 +50,7 @@ import {
createGroupAction, createGroupAction,
removeFromGroupAction, removeFromGroupAction,
sendAllInGroupAction, sendAllInGroupAction,
sendAllFromCreatorAction,
updateGroupPreviewAction, updateGroupPreviewAction,
mergeGroupsAction, mergeGroupsAction,
} from "../actions"; } from "../actions";
@@ -59,6 +61,7 @@ interface StlTableProps {
totalCount: number; totalCount: number;
ingestionStatus: IngestionAccountStatus[]; ingestionStatus: IngestionAccountStatus[];
availableTags: string[]; availableTags: string[];
availableCreators: string[];
searchTerm: string; searchTerm: string;
skippedData: SkippedRow[]; skippedData: SkippedRow[];
skippedPageCount: number; skippedPageCount: number;
@@ -74,6 +77,7 @@ export function StlTable({
totalCount, totalCount,
ingestionStatus, ingestionStatus,
availableTags, availableTags,
availableCreators,
searchTerm, searchTerm,
skippedData, skippedData,
skippedPageCount, skippedPageCount,
@@ -85,6 +89,7 @@ export function StlTable({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const activeCreator = searchParams.get("creator") ?? "";
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? ""); const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
const [viewPkg, setViewPkg] = useState<PackageRow | null>(null); const [viewPkg, setViewPkg] = useState<PackageRow | null>(null);
@@ -207,6 +212,20 @@ export function StlTable({
[router, pathname, searchParams] [router, pathname, searchParams]
); );
const updateCreatorFilter = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("creator", value);
params.set("page", "1");
} else {
params.delete("creator");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
},
[router, pathname, searchParams]
);
const activeTab = searchParams.get("tab") ?? "packages"; const activeTab = searchParams.get("tab") ?? "packages";
const updateTab = useCallback( const updateTab = useCallback(
@@ -277,6 +296,23 @@ export function StlTable({
[router] [router]
); );
const handleSendAllFromCreator = useCallback(() => {
if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return;
startTransition(async () => {
const result = await sendAllFromCreatorAction(activeCreator);
if (result.success) {
const { queued, skipped } = result.data;
toast.success(
`Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` +
(skipped ? ` (${skipped} already queued)` : "")
);
router.refresh();
} else {
toast.error(result.error);
}
});
}, [activeCreator, router]);
const handleRemoveFromGroup = useCallback( const handleRemoveFromGroup = useCallback(
(packageId: string) => { (packageId: string) => {
startTransition(async () => { startTransition(async () => {
@@ -464,14 +500,9 @@ export function StlTable({
</Badge> </Badge>
)} )}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="ungrouped" className="gap-1.5"> {/* "Ungrouped" tab hidden: the STL list is now flat and grouping is
Ungrouped no longer surfaced here. The tab content below is kept (unreachable)
{ungroupedTotalCount > 0 && ( to avoid churn; remove it and its data fetch if grouping is dropped. */}
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
{ungroupedTotalCount}
</Badge>
)}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="packages" className="space-y-4"> <TabsContent value="packages" className="space-y-4">
@@ -500,11 +531,29 @@ export function StlTable({
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
{availableCreators.length > 0 && (
<CreatorFilter
creators={availableCreators}
value={activeCreator}
onChange={updateCreatorFilter}
/>
)}
<DataTableViewOptions table={table} /> <DataTableViewOptions table={table} />
<Button variant="outline" size="sm" className="h-9" onClick={() => setUploadOpen(true)}> <Button variant="outline" size="sm" className="h-9" onClick={() => setUploadOpen(true)}>
<Upload className="mr-2 h-4 w-4" /> <Upload className="mr-2 h-4 w-4" />
Upload Files Upload Files
</Button> </Button>
{activeCreator && (
<Button
variant="outline"
size="sm"
className="h-9 gap-1.5"
onClick={handleSendAllFromCreator}
>
<Send className="h-3.5 w-3.5" />
Send all from {activeCreator}
</Button>
)}
{selectedPackages.size >= 2 && ( {selectedPackages.size >= 2 && (
<Button <Button
variant="outline" variant="outline"
+79
View File
@@ -589,3 +589,82 @@ export async function sendAllInGroupAction(
return { success: false, error: "Failed to send group packages" }; return { success: false, error: "Failed to send group packages" };
} }
} }
export async function sendAllFromCreatorAction(
creatorName: string
): Promise<ActionResult<{ queued: number; skipped: number }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const creator = creatorName.trim();
if (!creator) {
return { success: false, error: "No creator specified" };
}
try {
const telegramLink = await prisma.telegramLink.findUnique({
where: { userId: session.user.id },
});
if (!telegramLink) {
return { success: false, error: "No linked Telegram account. Link one in Settings." };
}
const sendablePackages = await prisma.package.findMany({
where: {
creator,
destChannelId: { not: null },
destMessageId: { not: null },
},
select: { id: true },
});
if (sendablePackages.length === 0) {
return { success: false, error: "No uploaded packages found for this creator" };
}
let queued = 0;
let skipped = 0;
for (const pkg of sendablePackages) {
// Only create if no existing PENDING/SENDING request for this package+link combo
const existing = await prisma.botSendRequest.findFirst({
where: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
status: { in: ["PENDING", "SENDING"] },
},
});
if (existing) {
skipped++;
continue;
}
const sendRequest = await prisma.botSendRequest.create({
data: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
requestedByUserId: session.user.id,
status: "PENDING",
},
});
// Notify the bot via pg_notify
try {
await prisma.$queryRawUnsafe(
`SELECT pg_notify('bot_send', $1)`,
sendRequest.id
);
} catch {
// Best-effort — the bot also polls periodically
}
queued++;
}
revalidatePath("/stls");
return { success: true, data: { queued, skipped } };
} catch {
return { success: false, error: "Failed to send creator packages" };
}
}
+4 -2
View File
@@ -1,6 +1,6 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages, listUngroupedPackages, countUngroupedPackages } from "@/lib/telegram/queries"; import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, getAllPackageCreators, listSkippedPackages, countSkippedPackages, listUngroupedPackages, countUngroupedPackages } from "@/lib/telegram/queries";
import { StlTable } from "./_components/stl-table"; import { StlTable } from "./_components/stl-table";
import type { DisplayItem, PackageListItem } from "@/lib/telegram/types"; import type { DisplayItem, PackageListItem } from "@/lib/telegram/types";
@@ -24,7 +24,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
const tab = (params.tab as string) ?? "packages"; const tab = (params.tab as string) ?? "packages";
// Fetch packages, ingestion status, tags, and skipped count in parallel // Fetch packages, ingestion status, tags, and skipped count in parallel
const [result, ingestionStatus, availableTags, skippedCount, ungroupedCount] = await Promise.all([ const [result, ingestionStatus, availableTags, availableCreators, skippedCount, ungroupedCount] = await Promise.all([
search search
? searchPackages({ ? searchPackages({
query: search, query: search,
@@ -42,6 +42,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
}), }),
getIngestionStatus(), getIngestionStatus(),
getAllPackageTags(), getAllPackageTags(),
getAllPackageCreators(),
countSkippedPackages(), countSkippedPackages(),
countUngroupedPackages(), countUngroupedPackages(),
]); ]);
@@ -68,6 +69,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
totalCount={result.pagination.total} totalCount={result.pagination.total}
ingestionStatus={ingestionStatus} ingestionStatus={ingestionStatus}
availableTags={availableTags} availableTags={availableTags}
availableCreators={availableCreators}
searchTerm={search} searchTerm={search}
skippedData={skippedResult?.items ?? []} skippedData={skippedResult?.items ?? []}
skippedPageCount={skippedResult?.pagination.totalPages ?? 0} skippedPageCount={skippedResult?.pagination.totalPages ?? 0}
@@ -9,6 +9,7 @@ import {
ArrowUpFromLine, ArrowUpFromLine,
RefreshCcw, RefreshCcw,
Tag, Tag,
MessagesSquare,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -27,6 +28,7 @@ interface ChannelColumnsProps {
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void; onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
onRescan: (id: string) => void; onRescan: (id: string) => void;
onSetCategory: (id: string, category: string | null) => void; onSetCategory: (id: string, category: string | null) => void;
onManageTopics: (id: string) => void;
} }
export function getChannelColumns({ export function getChannelColumns({
@@ -35,6 +37,7 @@ export function getChannelColumns({
onSetType, onSetType,
onRescan, onRescan,
onSetCategory, onSetCategory,
onManageTopics,
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] { }: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
return [ return [
{ {
@@ -147,6 +150,14 @@ export function getChannelColumns({
Rescan Channel Rescan Channel
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{row.original.type === "SOURCE" && row.original.isForum && (
<DropdownMenuItem
onClick={() => onManageTopics(row.original.id)}
>
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
Topics
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? ""); const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? "");
@@ -7,6 +7,7 @@ import { getChannelColumns } from "./channel-columns";
import { DestinationCard } from "./destination-card"; import { DestinationCard } from "./destination-card";
import { ChannelPickerDialog } from "./channel-picker-dialog"; import { ChannelPickerDialog } from "./channel-picker-dialog";
import { JoinChannelDialog } from "./join-channel-dialog"; import { JoinChannelDialog } from "./join-channel-dialog";
import { TopicsDrawer } from "./topics-drawer";
import { import {
deleteChannel, deleteChannel,
toggleChannelActive, toggleChannelActive,
@@ -32,6 +33,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
const [rescanId, setRescanId] = useState<string | null>(null); const [rescanId, setRescanId] = useState<string | null>(null);
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null); const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
const [joinDialogOpen, setJoinDialogOpen] = useState(false); const [joinDialogOpen, setJoinDialogOpen] = useState(false);
const [topicsChannelId, setTopicsChannelId] = useState<string | null>(null);
// Find the first authenticated account for "Fetch Channels" // Find the first authenticated account for "Fetch Channels"
const authenticatedAccounts = accounts.filter((a) => a.authState === "AUTHENTICATED" && a.isActive); const authenticatedAccounts = accounts.filter((a) => a.authState === "AUTHENTICATED" && a.isActive);
@@ -60,6 +62,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
else toast.error(result.error); else toast.error(result.error);
}); });
}, },
onManageTopics: (id) => setTopicsChannelId(id),
}); });
const { table } = useDataTable({ const { table } = useDataTable({
@@ -167,6 +170,17 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
open={joinDialogOpen} open={joinDialogOpen}
onOpenChange={setJoinDialogOpen} onOpenChange={setJoinDialogOpen}
/> />
<TopicsDrawer
channelId={topicsChannelId}
channelTitle={
channels.find((c) => c.id === topicsChannelId)?.title
}
open={!!topicsChannelId}
onOpenChange={(open) => {
if (!open) setTopicsChannelId(null);
}}
/>
</div> </div>
); );
} }
@@ -0,0 +1,182 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useTransition } from "react";
import { toast } from "sonner";
import { Loader2, Search } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { setTopicFetchEnabled } from "../actions";
interface TopicRow {
id: string;
topicId: string;
topicName: string | null;
fetchEnabled: boolean;
lastScannedAt: string | null;
}
interface TopicsDrawerProps {
channelId: string | null;
channelTitle?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function topicLabel(t: TopicRow): string {
return t.topicName ?? `Topic ${t.topicId}`;
}
export function TopicsDrawer({
channelId,
channelTitle,
open,
onOpenChange,
}: TopicsDrawerProps) {
const [topics, setTopics] = useState<TopicRow[]>([]);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState("");
const [, startTransition] = useTransition();
const [pendingId, setPendingId] = useState<string | null>(null);
const fetchTopics = useCallback(async () => {
if (!channelId) return;
setLoading(true);
try {
const res = await fetch(`/api/telegram/channels/${channelId}/topics`);
if (res.ok) setTopics(await res.json());
else toast.error("Failed to load topics");
} catch {
toast.error("Failed to load topics");
}
setLoading(false);
}, [channelId]);
useEffect(() => {
if (open && channelId) fetchTopics();
}, [open, channelId, fetchTopics]);
const handleOpenChange = (next: boolean) => {
// Reset the filter on close so the next open starts clean (the drawer is
// opened per-channel, so close-then-open is the normal channel switch).
if (!next) setFilter("");
onOpenChange(next);
};
const handleToggle = (topic: TopicRow, enabled: boolean) => {
// Optimistic update
setTopics((prev) =>
prev.map((t) => (t.id === topic.id ? { ...t, fetchEnabled: enabled } : t))
);
setPendingId(topic.id);
startTransition(async () => {
const result = await setTopicFetchEnabled(topic.id, enabled);
if (!result.success) {
toast.error(result.error ?? "Failed to update topic");
// Revert on failure
setTopics((prev) =>
prev.map((t) =>
t.id === topic.id ? { ...t, fetchEnabled: !enabled } : t
)
);
}
setPendingId(null);
});
};
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return topics;
return topics.filter((t) => topicLabel(t).toLowerCase().includes(q));
}, [topics, filter]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-3">
<div className="space-y-1">
<DialogTitle className="truncate pr-8">
Topics{channelTitle ? `: ${channelTitle}` : ""}
</DialogTitle>
<DialogDescription>
Enabled topics are scanned and their files fetched. Disable a topic
to stop fetching new files from it already-fetched files are kept.
</DialogDescription>
</div>
{topics.length > 0 && (
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter topics..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-9 h-9"
/>
</div>
)}
</DialogHeader>
{/* Native overflow scroll: the Radix ScrollArea viewport does not get a
bounded height inside this flex-column, max-h, vertically-centred
dialog, so the list overflowed instead of scrolling. flex-1 +
min-h-0 + overflow-y-auto is the canonical, touch-friendly fix. */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="px-6 py-4 space-y-2">
{loading ? (
<div className="flex items-center justify-center gap-2 py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Loading topics...
</span>
</div>
) : topics.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No topics discovered yet they&apos;ll appear here after the next
scan.
</p>
) : filtered.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No topics match &quot;{filter}&quot;.
</p>
) : (
filtered.map((topic) => (
<div
key={topic.id}
className="flex items-center justify-between gap-3 rounded-md border p-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{topicLabel(topic)}
</p>
<p className="text-xs text-muted-foreground">
{topic.fetchEnabled
? "Fetching enabled"
: "Fetching disabled"}
{topic.lastScannedAt
? ` · last scanned ${new Date(
topic.lastScannedAt
).toLocaleDateString()}`
: " · not scanned yet"}
</p>
</div>
<Switch
checked={topic.fetchEnabled}
disabled={pendingId === topic.id}
onCheckedChange={(checked) => handleToggle(topic, checked)}
aria-label={`Toggle fetching for ${topicLabel(topic)}`}
/>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -9,13 +9,14 @@ import {
Radio, Radio,
AlertTriangle, AlertTriangle,
RefreshCw, RefreshCw,
SkipForward,
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { triggerIngestion } from "../actions"; import { triggerIngestion, disableActiveTopic } from "../actions";
import type { IngestionAccountStatus } from "@/lib/telegram/types"; import type { IngestionAccountStatus } from "@/lib/telegram/types";
interface WorkerStatusPanelProps { interface WorkerStatusPanelProps {
@@ -218,6 +219,24 @@ function RunningStatus({
}: { }: {
run: NonNullable<IngestionAccountStatus["currentRun"]>; run: NonNullable<IngestionAccountStatus["currentRun"]>;
}) { }) {
const [isDisabling, startDisable] = useTransition();
const handleSkipTopic = () => {
const acmId = run.currentAccountChannelMapId;
const topicId = run.currentTopicId;
if (!acmId || !topicId) return;
startDisable(async () => {
const result = await disableActiveTopic(acmId, topicId);
if (result.success) {
toast.success(
"Topic disabled — the current file finishes, then the rest is skipped"
);
} else {
toast.error(result.error ?? "Failed to disable topic");
}
});
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -273,6 +292,27 @@ function RunningStatus({
</span> </span>
)} )}
</div> </div>
{/* Skip & disable the topic currently being processed */}
{run.currentTopicId && run.currentAccountChannelMapId && (
<div className="pl-6 pt-1">
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={handleSkipTopic}
disabled={isDisabling}
title="Finish the current file, skip the rest of this topic, and don't fetch it in future runs"
>
{isDisabling ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<SkipForward className="h-3 w-3" />
)}
Skip &amp; disable this topic
</Button>
</div>
)}
</div> </div>
); );
} }
+74
View File
@@ -436,6 +436,80 @@ export async function rescanChannel(channelId: string): Promise<ActionResult> {
} }
} }
// ── Topic fetch toggle ──
export async function setTopicFetchEnabled(
topicProgressId: string,
enabled: boolean
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const existing = await prisma.topicProgress.findUnique({
where: { id: topicProgressId },
});
if (!existing) return { success: false, error: "Topic not found" };
try {
await prisma.topicProgress.update({
where: { id: topicProgressId },
data: { fetchEnabled: enabled },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update topic" };
}
}
/**
* Disable the topic currently being processed by the worker, identified by its
* account-channel mapping + Telegram topic id (as exposed on the live run
* status). Upserts so a disabled row exists even if the worker hasn't persisted
* the topic yet. The worker honours this live: it finishes the in-flight file
* then skips the rest of the topic, and future runs skip it entirely.
*/
export async function disableActiveTopic(
accountChannelMapId: string,
topicId: string
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
let topicIdBig: bigint;
try {
topicIdBig = BigInt(topicId);
} catch {
return { success: false, error: "Invalid topic id" };
}
// BigInt("") / BigInt("0") don't throw — reject non-positive ids explicitly
// since this action is exported and admin-callable outside the UI button.
if (topicIdBig <= BigInt(0)) {
return { success: false, error: "Invalid topic id" };
}
try {
await prisma.topicProgress.upsert({
where: {
accountChannelMapId_topicId: {
accountChannelMapId,
topicId: topicIdBig,
},
},
create: {
accountChannelMapId,
topicId: topicIdBig,
fetchEnabled: false,
},
update: { fetchEnabled: false },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to disable topic" };
}
}
// ── Account-Channel link actions ── // ── Account-Channel link actions ──
export async function linkChannel( export async function linkChannel(
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
import { listChannelTopics } from "@/lib/telegram/admin-queries";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ channelId: string }> }
) {
const authResult = await authenticateApiRequest(request, true);
if ("error" in authResult) return authResult.error;
const { channelId } = await params;
const topics = await listChannelTopics(channelId);
return NextResponse.json(topics);
}
+9
View File
@@ -0,0 +1,9 @@
import { signOut } from "@/lib/auth";
// Server-side sign-out that clears the JWT session cookie and redirects to the
// login page. Used to recover from a stale session whose user no longer exists
// in the database (e.g. after a DB reset), which a client-only signOut can't
// reach because the app crashes before rendering the user menu.
export async function GET() {
await signOut({ redirectTo: "/login" });
}
+26 -9
View File
@@ -1,20 +1,37 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const DEFAULT_SETTINGS = {
lowStockThreshold: 20,
currency: "EUR",
theme: "dark",
units: "metric",
} as const;
export async function getUserSettings(userId: string) { export async function getUserSettings(userId: string) {
let settings = await prisma.userSettings.findUnique({ let settings = await prisma.userSettings.findUnique({
where: { userId }, where: { userId },
}); });
if (!settings) { if (!settings) {
settings = await prisma.userSettings.create({ try {
data: { settings = await prisma.userSettings.create({
userId, data: { userId, ...DEFAULT_SETTINGS },
lowStockThreshold: 20, });
currency: "EUR", } catch (err) {
theme: "dark", // The session's user may no longer exist (e.g. a stale JWT cookie after a
units: "metric", // database reset). Creating settings then hits a foreign-key violation
}, // (P2003). Don't crash the Server Component render — return unsaved
}); // defaults. The (app) layout guard redirects such stale sessions to
// sign-out, so this fallback is only ever momentarily visible.
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2003"
) {
return { id: "", userId, ...DEFAULT_SETTINGS };
}
throw err;
}
} }
return settings; return settings;
+22
View File
@@ -41,6 +41,7 @@ export async function listChannels() {
telegramId: c.telegramId.toString(), telegramId: c.telegramId.toString(),
title: c.title, title: c.title,
type: c.type, type: c.type,
isForum: c.isForum,
isActive: c.isActive, isActive: c.isActive,
category: c.category, category: c.category,
createdAt: c.createdAt.toISOString(), createdAt: c.createdAt.toISOString(),
@@ -140,3 +141,24 @@ export async function getUnlinkedChannels(accountId: string) {
telegramId: c.telegramId.toString(), telegramId: c.telegramId.toString(),
})); }));
} }
// ── Channel topic queries ──
export async function listChannelTopics(channelId: string) {
const rows = await prisma.topicProgress.findMany({
where: { accountChannelMap: { channelId } },
orderBy: [{ fetchEnabled: "desc" }, { topicName: "asc" }],
});
return rows.map((r) => ({
id: r.id,
topicId: r.topicId.toString(),
topicName: r.topicName,
fetchEnabled: r.fetchEnabled,
lastScannedAt: r.lastScannedAt?.toISOString() ?? null,
}));
}
export type ChannelTopicRow = Awaited<
ReturnType<typeof listChannelTopics>
>[number];
+79 -24
View File
@@ -9,6 +9,28 @@ import type {
PackageGroupRow, PackageGroupRow,
} from "./types"; } from "./types";
/**
* Returns the subset of the given IDs whose `previewData` column is non-null,
* WITHOUT transferring the (large) image bytes.
*
* List views only need a `hasPreview` boolean per row. Selecting `previewData`
* directly pulls every JPEG blob (avg ~700 KB, up to 2 MB) into the Node heap
* just to compare it against null — under concurrent requests this exhausts the
* container memory limit and crashes the process. This keeps the check in SQL.
*/
async function fetchPreviewFlags(
table: "packages" | "package_groups",
ids: string[]
): Promise<Set<string>> {
if (ids.length === 0) return new Set();
const placeholders = ids.map((_, i) => `$${i + 1}`).join(", ");
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(
`SELECT id FROM ${table} WHERE "previewData" IS NOT NULL AND id IN (${placeholders})`,
...ids
);
return new Set(rows.map((r) => r.id));
}
export async function listPackages(options: { export async function listPackages(options: {
page: number; page: number;
limit: number; limit: number;
@@ -40,13 +62,17 @@ export async function listPackages(options: {
indexedAt: true, indexedAt: true,
creator: true, creator: true,
tags: true, tags: true,
previewData: true, // check actual image data, not previewMsgId proxy
sourceChannel: { select: { id: true, title: true } }, sourceChannel: { select: { id: true, title: true } },
}, },
}), }),
prisma.package.count({ where }), prisma.package.count({ where }),
]); ]);
const previewIds = await fetchPreviewFlags(
"packages",
items.map((p) => p.id)
);
const mapped: PackageListItem[] = items.map((pkg) => ({ const mapped: PackageListItem[] = items.map((pkg) => ({
id: pkg.id, id: pkg.id,
fileName: pkg.fileName, fileName: pkg.fileName,
@@ -55,7 +81,7 @@ export async function listPackages(options: {
archiveType: pkg.archiveType, archiveType: pkg.archiveType,
fileCount: pkg.fileCount, fileCount: pkg.fileCount,
isMultipart: pkg.isMultipart, isMultipart: pkg.isMultipart,
hasPreview: pkg.previewData !== null, hasPreview: previewIds.has(pkg.id),
creator: pkg.creator, creator: pkg.creator,
tags: pkg.tags, tags: pkg.tags,
indexedAt: pkg.indexedAt.toISOString(), indexedAt: pkg.indexedAt.toISOString(),
@@ -109,31 +135,31 @@ export async function listDisplayItems(options: {
const sortCol = sortBy === "fileName" ? `"fileName"` : sortBy === "fileSize" ? `"fileSize"` : `"indexedAt"`; const sortCol = sortBy === "fileName" ? `"fileName"` : sortBy === "fileSize" ? `"fileSize"` : `"indexedAt"`;
const sortDir = order === "asc" ? "ASC" : "DESC"; const sortDir = order === "asc" ? "ASC" : "DESC";
// Step 1: Count display items // NOTE: The STL list is intentionally FLAT — every package is its own display
// row regardless of packageGroupId. Grouping is no longer surfaced in this
// view (the creator column/filter organizes the list instead). PackageGroup
// rows and the manual grouping actions still exist in the DB/UI; they just
// don't drive this list's layout anymore.
// Step 1: Count display items (one per package)
const countResult = await prisma.$queryRawUnsafe<[{ count: bigint }]>( const countResult = await prisma.$queryRawUnsafe<[{ count: bigint }]>(
`SELECT COUNT(*) AS count FROM ( `SELECT COUNT(*) AS count FROM packages p ${whereClause}`,
SELECT DISTINCT COALESCE(p."packageGroupId", p."id") AS display_id
FROM packages p
${whereClause}
) AS display_items`,
...params ...params
); );
const total = Number(countResult[0].count); const total = Number(countResult[0].count);
// Step 2: Get display item IDs for this page // Step 2: Get package IDs for this page
const limitParam = paramIdx++; const limitParam = paramIdx++;
const offsetParam = paramIdx++; const offsetParam = paramIdx++;
const displayRows = await prisma.$queryRawUnsafe< const displayRows = await prisma.$queryRawUnsafe<
{ display_id: string; display_type: string }[] { display_id: string; display_type: string }[]
>( >(
`SELECT `SELECT
COALESCE(p."packageGroupId", p."id") AS display_id, p."id" AS display_id,
CASE WHEN p."packageGroupId" IS NOT NULL THEN 'group' ELSE 'package' END AS display_type, 'package' AS display_type,
MAX(p.${sortCol}) AS sort_value p.${sortCol} AS sort_value
FROM packages p FROM packages p
${whereClause} ${whereClause}
GROUP BY COALESCE(p."packageGroupId", p."id"),
CASE WHEN p."packageGroupId" IS NOT NULL THEN 'group' ELSE 'package' END
ORDER BY sort_value ${sortDir} ORDER BY sort_value ${sortDir}
LIMIT $${limitParam} OFFSET $${offsetParam}`, LIMIT $${limitParam} OFFSET $${offsetParam}`,
...params, limit, (page - 1) * limit ...params, limit, (page - 1) * limit
@@ -149,7 +175,7 @@ export async function listDisplayItems(options: {
select: { select: {
id: true, fileName: true, fileSize: true, contentHash: true, id: true, fileName: true, fileSize: true, contentHash: true,
archiveType: true, fileCount: true, isMultipart: true, archiveType: true, fileCount: true, isMultipart: true,
indexedAt: true, creator: true, tags: true, previewData: true, indexedAt: true, creator: true, tags: true,
sourceChannel: { select: { id: true, title: true } }, sourceChannel: { select: { id: true, title: true } },
}, },
}) })
@@ -159,13 +185,13 @@ export async function listDisplayItems(options: {
? await prisma.packageGroup.findMany({ ? await prisma.packageGroup.findMany({
where: { id: { in: groupIds } }, where: { id: { in: groupIds } },
select: { select: {
id: true, name: true, previewData: true, id: true, name: true,
sourceChannel: { select: { id: true, title: true } }, sourceChannel: { select: { id: true, title: true } },
packages: { packages: {
select: { select: {
id: true, fileName: true, fileSize: true, contentHash: true, id: true, fileName: true, fileSize: true, contentHash: true,
archiveType: true, fileCount: true, isMultipart: true, archiveType: true, fileCount: true, isMultipart: true,
indexedAt: true, creator: true, tags: true, previewData: true, indexedAt: true, creator: true, tags: true,
sourceChannel: { select: { id: true, title: true } }, sourceChannel: { select: { id: true, title: true } },
}, },
orderBy: { indexedAt: "desc" }, orderBy: { indexedAt: "desc" },
@@ -174,6 +200,16 @@ export async function listDisplayItems(options: {
}) })
: []; : [];
// Compute hasPreview flags without transferring image bytes (see fetchPreviewFlags)
const allPackageIds = [
...standalonePackages.map((p) => p.id),
...groups.flatMap((g) => g.packages.map((p) => p.id)),
];
const [packagePreviewIds, groupPreviewIds] = await Promise.all([
fetchPreviewFlags("packages", allPackageIds),
fetchPreviewFlags("package_groups", groups.map((g) => g.id)),
]);
// Build DisplayItem array in the original sort order // Build DisplayItem array in the original sort order
const packageMap = new Map(standalonePackages.map((p) => [p.id, p])); const packageMap = new Map(standalonePackages.map((p) => [p.id, p]));
const groupMap = new Map(groups.map((g) => [g.id, g])); const groupMap = new Map(groups.map((g) => [g.id, g]));
@@ -191,7 +227,7 @@ export async function listDisplayItems(options: {
archiveType: pkg.archiveType, archiveType: pkg.archiveType,
fileCount: pkg.fileCount, fileCount: pkg.fileCount,
isMultipart: pkg.isMultipart, isMultipart: pkg.isMultipart,
hasPreview: pkg.previewData !== null, hasPreview: packagePreviewIds.has(pkg.id),
creator: pkg.creator, creator: pkg.creator,
tags: pkg.tags, tags: pkg.tags,
indexedAt: pkg.indexedAt.toISOString(), indexedAt: pkg.indexedAt.toISOString(),
@@ -209,7 +245,7 @@ export async function listDisplayItems(options: {
data: { data: {
id: grp.id, id: grp.id,
name: grp.name, name: grp.name,
hasPreview: grp.previewData !== null, hasPreview: groupPreviewIds.has(grp.id),
totalFileSize: grp.packages.reduce((sum, p) => sum + p.fileSize, BigInt(0)).toString(), totalFileSize: grp.packages.reduce((sum, p) => sum + p.fileSize, BigInt(0)).toString(),
totalFileCount: grp.packages.reduce((sum, p) => sum + p.fileCount, 0), totalFileCount: grp.packages.reduce((sum, p) => sum + p.fileCount, 0),
packageCount: grp.packages.length, packageCount: grp.packages.length,
@@ -227,7 +263,7 @@ export async function listDisplayItems(options: {
archiveType: pkg.archiveType, archiveType: pkg.archiveType,
fileCount: pkg.fileCount, fileCount: pkg.fileCount,
isMultipart: pkg.isMultipart, isMultipart: pkg.isMultipart,
hasPreview: pkg.previewData !== null, hasPreview: packagePreviewIds.has(pkg.id),
creator: pkg.creator, creator: pkg.creator,
tags: pkg.tags, tags: pkg.tags,
indexedAt: pkg.indexedAt.toISOString(), indexedAt: pkg.indexedAt.toISOString(),
@@ -440,13 +476,17 @@ export async function searchPackages(options: {
indexedAt: true, indexedAt: true,
creator: true, creator: true,
tags: true, tags: true,
previewData: true,
sourceChannel: { select: { id: true, title: true } }, sourceChannel: { select: { id: true, title: true } },
}, },
}), }),
Promise.resolve(allIds.length), Promise.resolve(allIds.length),
]); ]);
const previewIds = await fetchPreviewFlags(
"packages",
items.map((p) => p.id)
);
const mapped: PackageListItem[] = items.map((pkg) => ({ const mapped: PackageListItem[] = items.map((pkg) => ({
id: pkg.id, id: pkg.id,
fileName: pkg.fileName, fileName: pkg.fileName,
@@ -455,7 +495,7 @@ export async function searchPackages(options: {
archiveType: pkg.archiveType, archiveType: pkg.archiveType,
fileCount: pkg.fileCount, fileCount: pkg.fileCount,
isMultipart: pkg.isMultipart, isMultipart: pkg.isMultipart,
hasPreview: pkg.previewData !== null, hasPreview: previewIds.has(pkg.id),
creator: pkg.creator, creator: pkg.creator,
tags: pkg.tags, tags: pkg.tags,
indexedAt: pkg.indexedAt.toISOString(), indexedAt: pkg.indexedAt.toISOString(),
@@ -494,6 +534,15 @@ export async function getAllPackageTags(): Promise<string[]> {
return result.map((r) => r.tag); return result.map((r) => r.tag);
} }
export async function getAllPackageCreators(): Promise<string[]> {
const result = await prisma.$queryRaw<{ creator: string }[]>`
SELECT DISTINCT creator FROM packages
WHERE creator IS NOT NULL AND creator <> ''
ORDER BY creator
`;
return result.map((r) => r.creator);
}
export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> { export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
const accounts = await prisma.telegramAccount.findMany({ const accounts = await prisma.telegramAccount.findMany({
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
@@ -550,6 +599,8 @@ export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
totalBytes: currentRun.totalBytes?.toString() ?? null, totalBytes: currentRun.totalBytes?.toString() ?? null,
downloadPercent: currentRun.downloadPercent, downloadPercent: currentRun.downloadPercent,
lastActivityAt: currentRun.lastActivityAt?.toISOString() ?? null, lastActivityAt: currentRun.lastActivityAt?.toISOString() ?? null,
currentTopicId: currentRun.currentTopicId?.toString() ?? null,
currentAccountChannelMapId: currentRun.currentAccountChannelMapId,
} }
: null, : null,
}); });
@@ -634,13 +685,17 @@ export async function listUngroupedPackages(options: {
partCount: true, partCount: true,
tags: true, tags: true,
indexedAt: true, indexedAt: true,
previewData: true,
sourceChannel: { select: { id: true, title: true } }, sourceChannel: { select: { id: true, title: true } },
}, },
}), }),
prisma.package.count({ where }), prisma.package.count({ where }),
]); ]);
const previewIds = await fetchPreviewFlags(
"packages",
items.map((p) => p.id)
);
return { return {
items: items.map((p) => ({ items: items.map((p) => ({
id: p.id, id: p.id,
@@ -654,7 +709,7 @@ export async function listUngroupedPackages(options: {
partCount: p.partCount, partCount: p.partCount,
tags: p.tags, tags: p.tags,
indexedAt: p.indexedAt.toISOString(), indexedAt: p.indexedAt.toISOString(),
hasPreview: !!p.previewData, hasPreview: previewIds.has(p.id),
sourceChannel: p.sourceChannel, sourceChannel: p.sourceChannel,
matchedFileCount: 0, matchedFileCount: 0,
matchedByContent: false, matchedByContent: false,
+2
View File
@@ -123,5 +123,7 @@ export interface IngestionAccountStatus {
totalBytes: string | null; // BigInt serialized as string totalBytes: string | null; // BigInt serialized as string
downloadPercent: number | null; downloadPercent: number | null;
lastActivityAt: string | null; lastActivityAt: string | null;
currentTopicId: string | null; // BigInt serialized as string
currentAccountChannelMapId: string | null;
} | null; } | null;
} }
+1034 -1
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsx watch src/index.ts" "dev": "tsx watch src/index.ts",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.4.0", "@prisma/adapter-pg": "^7.4.0",
@@ -23,6 +25,7 @@
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
"prisma": "^7.4.0", "prisma": "^7.4.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5",
"vitest": "^3.2.4"
} }
} }
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { parseZipCentralDirectoryFromTail } from "./central-directory.js";
import { crc32 } from "zlib"; // Node 20+ exposes zlib.crc32
// Build a minimal STORE (no compression) ZIP in-memory with the given files.
function buildStoreZip(files: { name: string; data: Buffer }[]): Buffer {
const chunks: Buffer[] = [];
const central: Buffer[] = [];
let offset = 0;
for (const f of files) {
const crc = crc32(f.data) >>> 0;
const nameBuf = Buffer.from(f.name, "utf8");
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4); // version needed
local.writeUInt16LE(0, 6); // flags
local.writeUInt16LE(0, 8); // method = store
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(f.data.length, 18); // compressed
local.writeUInt32LE(f.data.length, 22); // uncompressed
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28); // extra len
const localHeader = Buffer.concat([local, nameBuf, f.data]);
chunks.push(localHeader);
const cd = Buffer.alloc(46);
cd.writeUInt32LE(0x02014b50, 0);
cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6);
cd.writeUInt16LE(0, 8); cd.writeUInt16LE(0, 10);
cd.writeUInt32LE(crc, 16);
cd.writeUInt32LE(f.data.length, 20);
cd.writeUInt32LE(f.data.length, 24);
cd.writeUInt16LE(nameBuf.length, 28);
cd.writeUInt32LE(offset, 42); // local header offset
central.push(Buffer.concat([cd, nameBuf]));
offset += localHeader.length;
}
const cdBuf = Buffer.concat(central);
const cdOffset = offset;
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(files.length, 8);
eocd.writeUInt16LE(files.length, 10);
eocd.writeUInt32LE(cdBuf.length, 12);
eocd.writeUInt32LE(cdOffset, 16);
return Buffer.concat([...chunks, cdBuf, eocd]);
}
describe("parseZipCentralDirectoryFromTail", () => {
it("lists entries with correct names, sizes, and crc32", () => {
const zip = buildStoreZip([
{ name: "models/dragon.stl", data: Buffer.from("DRAGON") },
{ name: "readme.txt", data: Buffer.from("hello world") },
]);
const entries = parseZipCentralDirectoryFromTail(zip, 0);
expect(entries.map((e) => e.fileName).sort()).toEqual(["dragon.stl", "readme.txt"]);
const dragon = entries.find((e) => e.fileName === "dragon.stl")!;
expect(dragon.path).toBe("models/dragon.stl");
expect(dragon.uncompressedSize).toBe(6n);
expect(dragon.crc32).toMatch(/^[0-9a-f]{8}$/);
});
it("throws when the central directory begins before the tail window", () => {
const zip = buildStoreZip([{ name: "a.txt", data: Buffer.alloc(100) }]);
// Provide only the last 30 bytes but claim they start at offset (len-30):
const tail = zip.subarray(zip.length - 30);
expect(() => parseZipCentralDirectoryFromTail(tail, zip.length - 30)).toThrow(RangeError);
});
});
+90
View File
@@ -0,0 +1,90 @@
import path from "path";
import type { FileEntry } from "./zip-reader.js";
export const MIN_ZIP_TAIL_BYTES = 65_557;
const EOCD_SIG = 0x06054b50;
const CD_SIG = 0x02014b50;
function extOf(name: string): string | null {
const e = path.extname(name).replace(/^\./, "").toLowerCase();
return e === "" ? null : e;
}
/** 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; }
}
if (eocd < 0) throw new RangeError("EOCD not found in tail");
let cdSize = tail.readUInt32LE(eocd + 12);
let cdOffset = tail.readUInt32LE(eocd + 16);
// ZIP64: sizes/offsets of 0xFFFFFFFF mean "see ZIP64 EOCD".
if (cdOffset === 0xffffffff || cdSize === 0xffffffff) {
const locSig = 0x07064b50;
let loc = -1;
for (let i = eocd - 20; i >= 0; i--) {
if (tail.readUInt32LE(i) === locSig) { loc = i; break; }
}
if (loc < 0) throw new RangeError("ZIP64 EOCD locator not in tail");
const z64Abs = Number(tail.readBigUInt64LE(loc + 8)); // absolute offset of ZIP64 EOCD
const z64 = z64Abs - tailStart;
if (z64 < 0) throw new RangeError("ZIP64 EOCD before tail window");
cdSize = Number(tail.readBigUInt64LE(z64 + 40));
cdOffset = Number(tail.readBigUInt64LE(z64 + 48));
}
// 2. Map the absolute central-directory offset into the tail buffer.
const cdLocal = cdOffset - tailStart;
if (cdLocal < 0 || cdLocal + cdSize > tail.length) {
throw new RangeError("Central directory begins before tail window");
}
// 3. Walk central-directory headers.
const entries: FileEntry[] = [];
let p = cdLocal;
const end = cdLocal + cdSize;
while (p + 46 <= end && 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));
const nameLen = tail.readUInt16LE(p + 28);
const extraLen = tail.readUInt16LE(p + 30);
const commentLen = tail.readUInt16LE(p + 32);
const name = tail.toString("utf8", p + 46, p + 46 + nameLen);
// ZIP64 extra field overrides 0xFFFFFFFF sizes.
if (comp === 0xffffffffn || uncomp === 0xffffffffn) {
let ep = p + 46 + nameLen;
const extraEnd = ep + extraLen;
while (ep + 4 <= extraEnd) {
const id = tail.readUInt16LE(ep);
const sz = tail.readUInt16LE(ep + 2);
if (id === 0x0001) {
let fp = ep + 4;
if (uncomp === 0xffffffffn) { uncomp = tail.readBigUInt64LE(fp); fp += 8; }
if (comp === 0xffffffffn) { comp = tail.readBigUInt64LE(fp); fp += 8; }
}
ep += 4 + sz;
}
}
const isDir = name.endsWith("/");
if (!isDir) {
entries.push({
path: name,
fileName: path.basename(name),
extension: extOf(name),
compressedSize: comp,
uncompressedSize: uncomp,
crc32: crc !== 0 ? crc.toString(16).padStart(8, "0") : null,
});
}
p += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { crcFingerprint, fingerprintsMatch } from "./fingerprint.js";
import type { FileEntry } from "./zip-reader.js";
const fe = (crc: string | null): FileEntry => ({
path: "a", fileName: "a", extension: null,
compressedSize: 0n, uncompressedSize: 0n, crc32: crc,
});
describe("crcFingerprint", () => {
it("sorts crcs and marks complete", () => {
expect(crcFingerprint([fe("00ff"), fe("00aa")])).toEqual({ crcs: ["00aa", "00ff"], complete: true });
});
it("is incomplete when any crc is null", () => {
expect(crcFingerprint([fe("00aa"), fe(null)]).complete).toBe(false);
});
it("is incomplete when empty", () => {
expect(crcFingerprint([]).complete).toBe(false);
});
});
describe("fingerprintsMatch", () => {
it("matches identical crc multisets regardless of order", () => {
expect(fingerprintsMatch([fe("01"), fe("02")], [fe("02"), fe("01")])).toBe(true);
});
it("rejects different counts", () => {
expect(fingerprintsMatch([fe("01")], [fe("01"), fe("02")])).toBe(false);
});
it("rejects disjoint sets", () => {
expect(fingerprintsMatch([fe("01")], [fe("09")])).toBe(false);
});
it("rejects when either side is incomplete", () => {
expect(fingerprintsMatch([fe("01"), fe(null)], [fe("01"), fe("02")])).toBe(false);
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { FileEntry } from "./zip-reader.js";
export function crcFingerprint(entries: FileEntry[]): { crcs: string[]; complete: boolean } {
if (entries.length === 0) return { crcs: [], complete: false };
const crcs: string[] = [];
let complete = true;
for (const e of entries) {
if (e.crc32 == null) { complete = false; continue; }
crcs.push(e.crc32.toLowerCase());
}
crcs.sort();
return { crcs, complete };
}
export function fingerprintsMatch(a: FileEntry[], b: FileEntry[]): boolean {
const fa = crcFingerprint(a);
const fb = crcFingerprint(b);
if (!fa.complete || !fb.complete) return false;
if (fa.crcs.length !== fb.crcs.length) return false;
return fa.crcs.every((c, i) => c === fb.crcs[i]);
}
+56 -21
View File
@@ -5,21 +5,28 @@ import { childLogger } from "../util/logger.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const log = childLogger("integrity"); const log = childLogger("integrity");
export type IntegrityFailureKind = "encrypted" | "corrupt" | "inconclusive";
export type IntegrityResult = export type IntegrityResult =
| { ok: true } | { ok: true }
| { ok: false; reason: string }; | { ok: false; reason: string; kind: IntegrityFailureKind };
/** /**
* Test that the archive can be read end-to-end without errors, BEFORE we * Test that the archive can be read end-to-end without errors, BEFORE we
* spend bandwidth uploading it to the destination channel. Catches: * spend bandwidth uploading it to the destination channel.
* - Truncated downloads (rare given our size check, but cheap to confirm)
* - CRC errors inside the archive
* - Bad central directories
* - Encrypted archives (we report them as failures rather than upload
* a file users can't extract)
* *
* Returns { ok: true } if the archive is intact. Returns * Failures are classified so the caller can react appropriately:
* { ok: false, reason } otherwise. Logs at warn level on failure. * - "encrypted" — password-protected; users can't extract it. Actionable.
* - "corrupt" — genuine CRC / structural error (truncated download, bad
* central directory, CRC mismatch). Actionable.
* - "inconclusive" — the test tool itself was killed (OOM) or timed out,
* typically on very large 7z archives in a memory-limited
* container (exit 137 / SIGKILL). This is a TOOL
* LIMITATION, not corruption — callers should NOT raise a
* user-facing alarm for it.
*
* Returns { ok: true } if the archive is intact, otherwise
* { ok: false, reason, kind }.
* *
* For multipart archives, pass the first part. unzip / unrar / 7z all * For multipart archives, pass the first part. unzip / unrar / 7z all
* auto-discover sibling parts. * auto-discover sibling parts.
@@ -42,7 +49,7 @@ export async function testArchiveIntegrity(
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
}); });
if (stderr && stderr.trim()) { if (stderr && stderr.trim()) {
return { ok: false, reason: `unzip -t reported: ${stderr.slice(0, 500)}` }; return { ok: false, kind: "corrupt", reason: `unzip -t reported: ${stderr.slice(0, 500)}` };
} }
return { ok: true }; return { ok: true };
} }
@@ -58,7 +65,7 @@ export async function testArchiveIntegrity(
if (/All OK/i.test(combined)) { if (/All OK/i.test(combined)) {
return { ok: true }; return { ok: true };
} }
return { ok: false, reason: `unrar t did not report "All OK": ${combined.slice(-500)}` }; return { ok: false, kind: "corrupt", reason: `unrar t did not report "All OK": ${combined.slice(-500)}` };
} }
if (archiveType === "SEVEN_Z") { if (archiveType === "SEVEN_Z") {
@@ -70,24 +77,52 @@ export async function testArchiveIntegrity(
if (/Everything is Ok/i.test(combined)) { if (/Everything is Ok/i.test(combined)) {
return { ok: true }; return { ok: true };
} }
return { ok: false, reason: `7z t did not report "Everything is Ok": ${combined.slice(-500)}` }; return { ok: false, kind: "corrupt", reason: `7z t did not report "Everything is Ok": ${combined.slice(-500)}` };
} }
return { ok: false, reason: `Unknown archive type: ${archiveType}` }; return { ok: false, kind: "corrupt", reason: `Unknown archive type: ${archiveType}` };
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
// execFile throws on non-zero exit. Try to extract the most useful part. // execFile throws on non-zero exit, on timeout, and when killed by a signal.
// eslint-disable-next-line @typescript-eslint/no-explicit-any const e = err as {
const stderr = (err as any)?.stderr as string | undefined; stderr?: unknown;
stdout?: unknown;
signal?: string | null;
killed?: boolean;
code?: number | string | null;
};
const stderr = typeof e.stderr === "string" ? e.stderr : "";
const stdout = typeof e.stdout === "string" ? e.stdout : "";
const detail = stderr ? `: ${stderr.slice(0, 500)}` : ""; const detail = stderr ? `: ${stderr.slice(0, 500)}` : "";
const haystack = `${msg}\n${stdout}\n${stderr}`;
// Specifically flag encrypted archives so the caller can record a more // Encrypted archives — users can't extract them, so flag clearly.
// specific SkipReason / notification. if (/password|encrypted|wrong password|enter password/i.test(haystack)) {
if (/password|encrypted|need.*password/i.test(`${msg}${detail}`)) { return { ok: false, kind: "encrypted", reason: `Archive is encrypted (password protected): ${msg}${detail}` };
return { ok: false, reason: `Archive is encrypted (password protected): ${msg}${detail}` };
} }
// Inconclusive — the test tool was killed or timed out rather than
// reporting corruption. Common on large 7z in memory-limited containers,
// where `7z t` gets OOM-killed (SIGKILL / exit 137) mid-decompression.
// That's a tool limitation, not a corrupt archive.
const killedBySignal = e.signal === "SIGKILL" || e.signal === "SIGTERM";
const killedExitCode = e.code === 137 || e.code === 143; // 128 + SIGKILL/SIGTERM
const timedOut = e.killed === true;
const maxBufferExceeded = e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
if (killedBySignal || killedExitCode || timedOut || maxBufferExceeded) {
log.debug(
{ err, archiveType, firstPartPath, signal: e.signal, code: e.code, killed: e.killed },
"Archive integrity test inconclusive (tool killed or timed out)"
);
return {
ok: false,
kind: "inconclusive",
reason: `Integrity test could not complete (tool killed or timed out — likely OOM on a large archive): ${msg}`,
};
}
// Genuine failure: a real non-zero exit with error output.
log.debug({ err, archiveType, firstPartPath }, "Archive integrity test failed"); log.debug({ err, archiveType, firstPartPath }, "Archive integrity test failed");
return { ok: false, reason: `Integrity test failed: ${msg}${detail}` }; return { ok: false, kind: "corrupt", reason: `Integrity test failed: ${msg}${detail}` };
} }
} }
@@ -0,0 +1,27 @@
import { describe, it, expect, vi } from "vitest";
// logLevel is required here too (not just maxZipSizeMB/tempDir) because this
// mock replaces the config module for the whole test-file graph, including
// util/logger.ts's module-level `pino({ level: config.logLevel })` call —
// pino throws at import time if level is undefined.
vi.mock("../../util/config.js", () => ({ config: { maxZipSizeMB: 1, tempDir: "/tmp", logLevel: "info" } }));
const created: unknown[] = [];
vi.mock("../../db/client.js", () => ({
db: { systemNotification: { create: async (a: unknown) => { created.push(a); } } },
}));
import { fullDownloadListing } from "./fallback.js";
describe("fullDownloadListing", () => {
it("refuses to download over the size cap and records a notification", async () => {
const res = await fullDownloadListing({
client: {} as never,
parts: [{ fileId: "1", fileSize: 2n * 1024n * 1024n * 1024n, fileName: "big.rar" }],
archiveType: "RAR",
totalSize: 2n * 1024n * 1024n * 1024n,
fileName: "big.rar",
});
expect(res).toBeNull();
expect(created).toHaveLength(1);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { mkdtemp, rm } from "fs/promises";
import path from "path";
import type { Client } from "tdl";
import { config } from "../../util/config.js";
import { db } from "../../db/client.js";
import { childLogger } from "../../util/logger.js";
import { downloadFile } from "../../tdlib/download.js";
import { read7zContents } from "../sevenz-reader.js";
import { readRarContents } from "../rar-reader.js";
import type { FileEntry } from "../zip-reader.js";
import type { RangedPart } from "./sevenz-ranged.js";
const log = childLogger("ranged-fallback");
export async function fullDownloadListing(args: {
client: Client;
parts: RangedPart[];
archiveType: string;
totalSize: bigint;
fileName: string;
}): Promise<FileEntry[] | null> {
const capBytes = BigInt(config.maxZipSizeMB) * 1024n * 1024n;
if (args.totalSize > capBytes) {
await db.systemNotification.create({
data: {
type: "INTEGRITY_AUDIT",
severity: "WARNING",
title: `Listing skipped (over size cap): ${args.fileName}`,
message: `Ranged listing failed and the archive (${args.totalSize} bytes) exceeds WORKER_MAX_ZIP_SIZE_MB; not downloaded. Inner files left unindexed.`,
context: { fileName: args.fileName, archiveType: args.archiveType },
},
});
log.warn({ fileName: args.fileName }, "fallback skipped — over size cap");
return null;
}
const dir = await mkdtemp(path.join(config.tempDir, "fallback-"));
const paths: string[] = [];
try {
for (const p of args.parts) {
const dest = path.join(dir, p.fileName);
await downloadFile(args.client, p.fileId, dest, p.fileSize, p.fileName, () => {});
paths.push(dest);
}
const entries =
args.archiveType === "SEVEN_Z" ? await read7zContents(paths[0])
: args.archiveType === "RAR" ? await readRarContents(paths[0])
: [];
return entries.length > 0 ? entries : null;
} catch (err) {
log.warn({ err, fileName: args.fileName }, "full-download fallback failed");
return null;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { Client } from "tdl";
import { downloadFileRange } from "../../tdlib/range-download.js";
export type RangeReader = (
fileId: string,
offset: number,
length: number,
partSize: bigint,
) => Promise<Buffer>;
export function tdlibRangeReader(client: Client): RangeReader {
return (fileId, offset, length, partSize) =>
downloadFileRange(client, fileId, offset, length, partSize);
}
@@ -0,0 +1,150 @@
import { describe, it, expect } from "vitest";
import { readVint, detectRarSignature, parseRar5BlockExtent, parseRar4BlockExtent, walkRarVolume, readRarListingRanged } from "./rar-ranged.js";
import type { RangeReader } from "./range-reader.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 });
// 0x80,0x01 => 0 | (1<<7) = 128
expect(readVint(Buffer.from([0x80, 0x01]), 0)).toEqual({ value: 128, bytes: 2 });
});
});
describe("detectRarSignature", () => {
it("detects RAR5 and RAR4", () => {
expect(detectRarSignature(Buffer.from([0x52,0x61,0x72,0x21,0x1a,0x07,0x01,0x00]))).toEqual({ version: 5, sigLen: 8 });
expect(detectRarSignature(Buffer.from([0x52,0x61,0x72,0x21,0x1a,0x07,0x00]))).toEqual({ version: 4, sigLen: 7 });
expect(detectRarSignature(Buffer.alloc(8))).toBeNull();
});
});
describe("parseRar5BlockExtent", () => {
it("computes header+data extent and flags end-of-archive", () => {
// CRC32(4) | HeaderSize vint=5 | Type vint=2 (file) | Flags vint=2 (data present) | DataSize vint=100 | (pad to headerSize)
const b = Buffer.concat([
Buffer.from([0,0,0,0]), // CRC
Buffer.from([0x05]), // HeaderSize = 5 (bytes after this vint)
Buffer.from([0x02]), // Type = 2 (file)
Buffer.from([0x02]), // Flags = 0x02 -> data present
Buffer.from([0x64]), // DataSize = 100
Buffer.from([0x00, 0x00]), // padding to fill HeaderSize(5): Type+Flags+DataSize=3, +2 pad =5
]);
const ext = parseRar5BlockExtent(b, 0);
// headerBytes = 4 (CRC) + 1 (HeaderSize vint) + 5 (HeaderSize) = 10
expect(ext.headerBytes).toBe(10);
expect(ext.dataSize).toBe(100);
expect(ext.isEnd).toBe(false);
const endBlk = Buffer.from([0,0,0,0, 0x02, 0x05, 0x00]); // HeaderSize=2, Type=5(end), Flags=0
const e2 = parseRar5BlockExtent(endBlk, 0);
expect(e2.isEnd).toBe(true);
});
});
describe("parseRar4BlockExtent", () => {
it("computes extent with ADD_SIZE when flag 0x8000 is set", () => {
// CRC(2) TYPE(1)=0x74 FLAGS(2)=0x8000 HEAD_SIZE(2)=11 ADD_SIZE(4)=200
const b = Buffer.alloc(11);
b.writeUInt8(0x74, 2);
b.writeUInt16LE(0x8000, 3);
b.writeUInt16LE(11, 5);
b.writeUInt32LE(200, 7);
const ext = parseRar4BlockExtent(b, 0);
expect(ext.headerBytes).toBe(11);
expect(ext.dataSize).toBe(200);
expect(ext.isEnd).toBe(false);
});
});
// Build a synthetic RAR5 volume: signature + main header + 2 file blocks (each
// with data) + end block. We only need extents to be walkable.
function buildRar5Volume(): Buffer {
const sig = Buffer.from([0x52,0x61,0x72,0x21,0x1a,0x07,0x01,0x00]);
const block = (type: number, flags: number, dataSize: number, pad = 0) => {
const body = [Buffer.from([type]), Buffer.from([flags])];
if (flags & 0x0002) body.push(Buffer.from([dataSize])); // DataSize (<=127 for test)
if (pad) body.push(Buffer.alloc(pad));
const bodyBuf = Buffer.concat(body);
const hs = Buffer.from([bodyBuf.length]); // HeaderSize vint (<=127)
const header = Buffer.concat([Buffer.alloc(4), hs, bodyBuf]); // CRC(4)+HeaderSize+body
const data = Buffer.alloc(flags & 0x0002 ? dataSize : 0, 0xEE);
return Buffer.concat([header, data]);
};
const main = block(1, 0, 0); // main archive header, no data
const f1 = block(2, 0x02, 20); // file header + 20 bytes data
const f2 = block(2, 0x02, 30); // file header + 30 bytes data
const end = block(5, 0, 0); // end of archive
return Buffer.concat([sig, main, f1, f2, end]);
}
describe("walkRarVolume", () => {
it("harvests every block header and stops at end-of-archive", async () => {
const vol = buildRar5Volume();
const read: RangeReader = async (_id, offset, length) => vol.subarray(offset, offset + length);
const regions = await walkRarVolume(read, { fileId: "1", fileSize: BigInt(vol.length), fileName: "a.rar" }, 5, 8);
expect(regions).not.toBeNull();
// main + 2 files + end = 4 header regions
expect(regions!).toHaveLength(4);
// First region starts right after the 8-byte signature
expect(regions![0].offset).toBe(8);
});
it("returns null when a block claims an absurd header size (corrupt/desynced)", async () => {
// RAR5 block with HeaderSize vint encoding a value > 8MB.
// Encode 9_000_000 as RAR vint: bytes little-endian 7-bit groups with continuation bit.
function encodeVint(n: number): number[] {
const out: number[] = [];
while (n >= 0x80) {
out.push((n & 0x7f) | 0x80);
n = Math.floor(n / 128);
}
out.push(n);
return out;
}
const sig = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]); // RAR5 signature
const hsVint = encodeVint(9_000_000);
// Block = CRC(4) + HeaderSize vint(9MB) + Type(1 byte) + Flags(1 byte)
const block = Buffer.concat([Buffer.alloc(4), Buffer.from(hsVint), Buffer.from([0x02, 0x00])]);
const vol = Buffer.concat([sig, block]);
const size = 20 * 1024 * 1024;
const read = async (_id: string, offset: number, length: number) => {
if (offset >= vol.length) return Buffer.alloc(0);
return vol.subarray(offset, Math.min(offset + length, vol.length));
};
const regions = await walkRarVolume(read, { fileId: "1", fileSize: BigInt(size), fileName: "c.rar" }, 5, 8);
expect(regions).toBeNull();
});
});
describe("readRarListingRanged (single part)", () => {
it("returns null cleanly when the reconstructed file isn't a real RAR", async () => {
const vol = buildRar5Volume();
const read: RangeReader = async (_id, offset, length) => vol.subarray(offset, offset + length);
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
});
});
describe("readRarListingRanged (multipart)", () => {
it("walks each volume from its own signature and reconstructs all parts", async () => {
const vol = buildRar5Volume(); // reuse from Task 6 test
// Two volumes with identical structure; each RangeReader read is scoped by fileId.
const byId: Record<string, Buffer> = { p1: vol, p2: vol };
const reads: Record<string, number> = { p1: 0, p2: 0 };
const read: RangeReader = async (fileId, offset, length) => {
reads[fileId]++;
return byId[fileId].subarray(offset, offset + length);
};
const res = await readRarListingRanged(
[
{ fileId: "p1", fileSize: BigInt(vol.length), fileName: "x.part1.rar" },
{ fileId: "p2", fileSize: BigInt(vol.length), fileName: "x.part2.rar" },
],
read,
);
// Both volumes were walked (each read at least its signature + blocks).
expect(reads.p1).toBeGreaterThan(0);
expect(reads.p2).toBeGreaterThan(0);
expect(res === null || Array.isArray(res)).toBe(true);
});
});
+110
View File
@@ -0,0 +1,110 @@
const RAR4_SIG = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00]);
const RAR5_SIG = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]);
export function readVint(buf: Buffer, pos: number): { value: number; bytes: number } {
let value = 0, shift = 0, bytes = 0;
while (pos + bytes < buf.length) {
const b = buf[pos + bytes];
value += (b & 0x7f) * Math.pow(2, shift); // Math.pow keeps >32-bit sizes exact up to 2^53
bytes++;
if ((b & 0x80) === 0) return { value, bytes };
shift += 7;
if (shift > 63) break;
}
throw new RangeError("incomplete RAR vint");
}
export function detectRarSignature(buf: Buffer): { version: 4 | 5; sigLen: number } | null {
if (buf.length >= 8 && buf.subarray(0, 8).equals(RAR5_SIG)) return { version: 5, sigLen: 8 };
if (buf.length >= 7 && buf.subarray(0, 7).equals(RAR4_SIG)) return { version: 4, sigLen: 7 };
return null;
}
export interface BlockExtent { headerBytes: number; dataSize: number; isEnd: boolean }
// RAR5: CRC32(4) | HeaderSize(vint) | HeaderType(vint) | HeaderFlags(vint)
// [ExtraAreaSize(vint) if flags&0x0001] [DataSize(vint) if flags&0x0002] ...
export function parseRar5BlockExtent(buf: Buffer, pos: number): BlockExtent {
let p = pos + 4; // skip CRC32
const hs = readVint(buf, p); p += hs.bytes;
const headerBytes = 4 + hs.bytes + hs.value; // CRC + HeaderSize-vint + HeaderSize
const type = readVint(buf, p); p += type.bytes;
const flags = readVint(buf, p); p += flags.bytes;
if (flags.value & 0x0001) { const ea = readVint(buf, p); p += ea.bytes; } // extra area size (skip)
let dataSize = 0;
if (flags.value & 0x0002) { const ds = readVint(buf, p); p += ds.bytes; dataSize = ds.value; }
return { headerBytes, dataSize, isEnd: type.value === 5 };
}
// RAR4: HEAD_CRC(2) | HEAD_TYPE(1) | HEAD_FLAGS(2) | HEAD_SIZE(2) [ADD_SIZE(4) if flags&0x8000]
export function parseRar4BlockExtent(buf: Buffer, pos: number): BlockExtent {
const type = buf.readUInt8(pos + 2);
const flags = buf.readUInt16LE(pos + 3);
const headSize = buf.readUInt16LE(pos + 5);
const dataSize = (flags & 0x8000) ? buf.readUInt32LE(pos + 7) : 0;
return { headerBytes: headSize, dataSize, isEnd: type === 0x7b };
}
import type { FileEntry } from "../zip-reader.js";
import type { RangeReader } from "./range-reader.js";
import type { RangedPart } from "./sevenz-ranged.js";
import { listFromSparse, type SparsePart } from "./sparse-list.js";
import { readRarContents } from "../rar-reader.js";
import { childLogger } from "../../util/logger.js";
const rlog = childLogger("rar-ranged");
const MAX_RAR_BLOCKS = 50000;
const MAX_RAR_HEADER_BYTES = 8 * 1024 * 1024; // 8 MB — real RAR block headers are far smaller; guards against a corrupt/desynced HeaderSize
const HEADER_CHUNK = 8192;
export async function walkRarVolume(
read: RangeReader,
part: RangedPart,
version: 4 | 5,
sigLen: number,
): Promise<{ offset: number; bytes: Buffer }[] | null> {
const size = Number(part.fileSize);
const regions: { offset: number; bytes: Buffer }[] = [];
let pos = sigLen;
let blocks = 0;
try {
while (pos < size) {
if (++blocks > 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;
// Ensure we have the full header bytes to harvest (long filenames).
let headerBuf = chunk;
if (ext.headerBytes > chunk.length) {
headerBuf = await read(part.fileId, pos, Math.min(ext.headerBytes, size - pos), part.fileSize);
}
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 (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");
return null;
}
}
export async function readRarListingRanged(
parts: RangedPart[],
read: RangeReader,
): Promise<FileEntry[] | null> {
const sparseParts: SparsePart[] = [];
for (const part of parts) {
const head = await read(part.fileId, 0, 16, part.fileSize);
const sig = detectRarSignature(head);
if (!sig) 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 });
}
return listFromSparse(sparseParts, readRarContents);
}
@@ -0,0 +1,133 @@
import { describe, it, expect } from "vitest";
import { parseSevenZSignatureHeader } from "./sevenz-ranged.js";
const MAGIC = Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]);
function buildSignatureHeader(nextOffset: bigint, nextSize: bigint): Buffer {
const buf = Buffer.alloc(32);
MAGIC.copy(buf, 0);
buf.writeUInt8(0, 6); buf.writeUInt8(4, 7); // version 0.4
buf.writeUInt32LE(0, 8); // StartHeaderCRC (unused here)
buf.writeBigUInt64LE(nextOffset, 12);
buf.writeBigUInt64LE(nextSize, 20);
buf.writeUInt32LE(0, 28); // NextHeaderCRC (unused here)
return buf;
}
describe("parseSevenZSignatureHeader", () => {
it("reads NextHeaderOffset and NextHeaderSize", () => {
const buf = buildSignatureHeader(1_000_000n, 4096n);
expect(parseSevenZSignatureHeader(buf)).toEqual({ nextHeaderOffset: 1_000_000, nextHeaderSize: 4096 });
});
it("returns null on bad magic", () => {
expect(parseSevenZSignatureHeader(Buffer.alloc(32))).toBeNull();
});
it("returns null when shorter than 32 bytes", () => {
expect(parseSevenZSignatureHeader(MAGIC)).toBeNull();
});
});
import { readSevenZListingRanged } from "./sevenz-ranged.js";
import type { RangeReader } from "./range-reader.js";
describe("readSevenZListingRanged", () => {
it("reads the signature + end-header regions and reconstructs for 7z l", async () => {
const size = 5_000_000;
const endHeaderOffset = 4_900_000; // absolute
const nextHeaderOffset = endHeaderOffset - 32;
const sig = Buffer.alloc(32);
Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]).copy(sig, 0);
sig.writeBigUInt64LE(BigInt(nextHeaderOffset), 12);
sig.writeBigUInt64LE(100n, 20);
const reads: { offset: number; length: number }[] = [];
const read: RangeReader = async (_id, offset, length) => {
reads.push({ offset, length });
if (offset === 0) return sig.subarray(0, length);
return Buffer.alloc(length, 0xAB); // stand-in end-header bytes
};
// Inject a fake lister via the module boundary: readSevenZListingRanged
// calls listFromSparse(parts, read7zContents). We assert the ranged reads
// it issued; the sparse file + real 7z is covered by live verification.
const entries = await readSevenZListingRanged(
[{ fileId: "1", fileSize: BigInt(size), fileName: "a.7z" }],
read,
);
// entries may be null here because the stand-in bytes aren't a real 7z;
// the contract under test is the ranged-read offsets:
expect(reads[0]).toEqual({ offset: 0, length: 32 });
expect(reads[1]).toEqual({ offset: endHeaderOffset, length: 100 });
expect(entries === null || Array.isArray(entries)).toBe(true);
});
});
import { read7zNumber, locate7zEncodedHeaderPack } from "./sevenz-ranged.js";
describe("read7zNumber", () => {
it("reads a single-byte number", () => {
expect(read7zNumber(Buffer.from([0x2a]), 0)).toEqual({ value: 42, next: 1 });
});
it("reads a two-byte number (first-byte length mask + LE trailing byte)", () => {
// 1000 = 0x03E8: low byte 0xE8, high nibble 0x03 -> first = 0x80|0x03 = 0x83, trailing 0xE8
expect(read7zNumber(Buffer.from([0x83, 0xe8]), 0)).toEqual({ value: 1000, next: 2 });
// 500 = 0x01F4 -> first 0x81, trailing 0xF4
expect(read7zNumber(Buffer.from([0x81, 0xf4]), 0)).toEqual({ value: 500, next: 2 });
});
it("throws when pos starts past the buffer end (short read)", () => {
expect(() => read7zNumber(Buffer.from([0x2a]), 5)).toThrow(RangeError);
expect(() => read7zNumber(Buffer.alloc(0), 0)).toThrow(RangeError);
});
});
describe("locate7zEncodedHeaderPack", () => {
it("parses PackPos and summed PackSize from an encoded header", () => {
// kEncodedHeader, kPackInfo, PackPos=1000([0x83,0xe8]), NumStreams=1([0x01]),
// kSize, PackSize=500([0x81,0xf4])
const enc = Buffer.from([0x17, 0x06, 0x83, 0xe8, 0x01, 0x09, 0x81, 0xf4]);
expect(locate7zEncodedHeaderPack(enc)).toEqual({ packPos: 1000, packSize: 500 });
});
it("returns null for a plain (kHeader 0x01) header", () => {
expect(locate7zEncodedHeaderPack(Buffer.from([0x01, 0x04]))).toBeNull();
});
it("sums multiple pack streams", () => {
// PackPos=0([0x00]), NumStreams=2([0x02]), kSize, sizes 10([0x0a]) + 20([0x14])
const enc = Buffer.from([0x17, 0x06, 0x00, 0x02, 0x09, 0x0a, 0x14]);
expect(locate7zEncodedHeaderPack(enc)).toEqual({ packPos: 0, packSize: 30 });
});
});
describe("readSevenZListingRanged (encoded header)", () => {
it("fetches the mid-file packed-header region as a third read", async () => {
const size = 5_000_000;
const nextHeaderOffset = 4_000_000; // relative to end of 32-byte sig header
const endStart = 32 + nextHeaderOffset; // absolute
const packPos = 1000; // relative to end of sig header
const packStart = 32 + packPos; // absolute
const packSize = 500;
const sig = Buffer.alloc(32);
Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]).copy(sig, 0);
sig.writeBigUInt64LE(BigInt(nextHeaderOffset), 12);
sig.writeBigUInt64LE(8n, 20); // NextHeaderSize = 8 (the encoded-header descriptor below)
const encHeader = Buffer.from([0x17, 0x06, 0x83, 0xe8, 0x01, 0x09, 0x81, 0xf4]);
const reads: { offset: number; length: number }[] = [];
const read = async (_id: string, offset: number, length: number) => {
reads.push({ offset, length });
if (offset === 0) return sig.subarray(0, length);
if (offset === endStart) return encHeader.subarray(0, length);
return Buffer.alloc(length, 0xcd); // stand-in packed-header bytes
};
const entries = await readSevenZListingRanged(
[{ fileId: "1", fileSize: BigInt(size), fileName: "a.7z" }],
read,
);
expect(reads[0]).toEqual({ offset: 0, length: 32 });
expect(reads[1]).toEqual({ offset: endStart, length: 8 });
expect(reads[2]).toEqual({ offset: packStart, length: packSize });
expect(entries === null || Array.isArray(entries)).toBe(true);
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { FileEntry } from "../zip-reader.js";
import { read7zContents } from "../sevenz-reader.js";
import { listFromSparse } from "./sparse-list.js";
import type { RangeReader } from "./range-reader.js";
import { childLogger } from "../../util/logger.js";
const log = childLogger("sevenz-ranged");
const SEVENZ_MAGIC = Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]);
const K_HEADER = 0x01;
const K_ENCODED_HEADER = 0x17;
const K_PACK_INFO = 0x06;
const K_SIZE = 0x09;
/** 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 } {
if (pos >= buf.length) throw new RangeError("7z number reads past buffer end");
const first = buf[pos];
let mask = 0x80;
let value = 0;
let p = pos + 1;
for (let i = 0; i < 8; i++) {
if ((first & mask) === 0) {
value += (first & (mask - 1)) * Math.pow(2, 8 * i);
return { value, next: p };
}
if (p >= buf.length) throw new RangeError("7z number overruns buffer");
value += buf[p] * Math.pow(2, 8 * i);
p++;
mask >>= 1;
}
return { value, next: p };
}
/** For an encoded (kEncodedHeader) 7z next-header, return the absolute-ish
* location of the packed header stream(s): PackPos (relative to end of the
* 32-byte signature header) and the summed PackSize. Null if not encoded or
* the StreamsInfo doesn't start with PackInfo as expected. */
export function locate7zEncodedHeaderPack(
nextHeader: Buffer,
): { packPos: number; packSize: number } | null {
let p = 0;
if (nextHeader[p] !== K_ENCODED_HEADER) return null;
p++;
if (nextHeader[p] !== K_PACK_INFO) return null;
p++;
const packPos = read7zNumber(nextHeader, p); p = packPos.next;
const numStreams = read7zNumber(nextHeader, p); p = numStreams.next;
if (nextHeader[p] !== K_SIZE) return null;
p++;
let total = 0;
for (let i = 0; i < numStreams.value; i++) {
const s = read7zNumber(nextHeader, p); p = s.next;
total += s.value;
}
return { packPos: packPos.value, packSize: total };
}
export function parseSevenZSignatureHeader(
buf: Buffer,
): { nextHeaderOffset: number; nextHeaderSize: number } | null {
if (buf.length < 32) return null;
if (!buf.subarray(0, 6).equals(SEVENZ_MAGIC)) return null;
return {
nextHeaderOffset: Number(buf.readBigUInt64LE(12)),
nextHeaderSize: Number(buf.readBigUInt64LE(20)),
};
}
export interface RangedPart { fileId: string; fileSize: bigint; fileName: string }
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;
}
}
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { open } from "fs/promises";
import { listFromSparse } from "./sparse-list.js";
describe("listFromSparse", () => {
it("writes each region at its offset into a sparse file and passes the path to the lister", async () => {
const size = 1_000_000;
const regions = [
{ offset: 0, bytes: Buffer.from("HEAD") },
{ offset: size - 4, bytes: Buffer.from("TAIL") },
];
let seenPath = "";
const entries = await listFromSparse(
[{ fileName: "sample.7z", size, regions }],
async (firstPartPath) => {
seenPath = firstPartPath;
const fh = await open(firstPartPath, "r");
try {
const head = Buffer.alloc(4); await fh.read(head, 0, 4, 0);
const tail = Buffer.alloc(4); await fh.read(tail, 0, 4, size - 4);
const hole = Buffer.alloc(4); await fh.read(hole, 0, 4, 500_000);
expect(head.toString()).toBe("HEAD");
expect(tail.toString()).toBe("TAIL");
expect(hole.equals(Buffer.alloc(4))).toBe(true); // gap is zero
} finally { await fh.close(); }
return [{ path: "a/b.stl", fileName: "b.stl", extension: "stl", compressedSize: 1n, uncompressedSize: 1n, crc32: null }];
},
);
expect(seenPath.endsWith("sample.7z")).toBe(true);
expect(entries).not.toBeNull();
expect(entries!).toHaveLength(1);
});
it("returns null when the lister yields no entries", async () => {
const res = await listFromSparse(
[{ fileName: "x.7z", size: 100, regions: [{ offset: 0, bytes: Buffer.from("A") }] }],
async () => [],
);
expect(res).toBeNull();
});
});
+52
View File
@@ -0,0 +1,52 @@
import { mkdtemp, open, rm } from "fs/promises";
import path from "path";
import { config } from "../../util/config.js";
import { childLogger } from "../../util/logger.js";
import type { FileEntry } from "../zip-reader.js";
const log = childLogger("sparse-list");
export interface SparsePart {
fileName: string;
size: number;
regions: { offset: number; bytes: Buffer }[];
}
export type SparseLister = (firstPartPath: string) => Promise<FileEntry[]>;
/**
* Reconstruct archive header bytes into sparse temp files (data areas left as
* zero holes), run `lister` on the first part, return its entries.
* Returns null on any error or when the lister finds nothing.
*/
export async function listFromSparse(
parts: SparsePart[],
lister: SparseLister,
): Promise<FileEntry[] | null> {
if (parts.length === 0) return null;
const dir = await mkdtemp(path.join(config.tempDir, "ranged-"));
try {
let firstPath = "";
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
const filePath = path.join(dir, p.fileName);
if (i === 0) firstPath = filePath;
const fh = await open(filePath, "w");
try {
await fh.truncate(p.size); // create the sparse hole
for (const r of p.regions) {
await fh.write(r.bytes, 0, r.bytes.length, r.offset);
}
} finally {
await fh.close();
}
}
const entries = await lister(firstPath);
return entries.length > 0 ? entries : null;
} catch (err) {
log.warn({ err }, "sparse listing failed");
return null;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
+257
View File
@@ -1,5 +1,6 @@
import { db } from "./client.js"; import { db } from "./client.js";
import type { ArchiveType, FetchStatus } from "@prisma/client"; import type { ArchiveType, FetchStatus } from "@prisma/client";
import type { FileEntry } from "../archive/zip-reader.js";
export async function getActiveAccounts() { export async function getActiveAccounts() {
return db.telegramAccount.findMany({ return db.telegramAccount.findMany({
@@ -369,10 +370,13 @@ export interface ActivityUpdate {
downloadedBytes?: bigint | null; downloadedBytes?: bigint | null;
totalBytes?: bigint | null; totalBytes?: bigint | null;
downloadPercent?: number | null; downloadPercent?: number | null;
currentTopicId?: bigint | null;
currentAccountChannelMapId?: string | null;
messagesScanned?: number; messagesScanned?: number;
zipsFound?: number; zipsFound?: number;
zipsDuplicate?: number; zipsDuplicate?: number;
zipsIngested?: number; zipsIngested?: number;
zipsBackfilled?: number;
} }
export async function updateRunActivity( export async function updateRunActivity(
@@ -396,6 +400,11 @@ export async function updateRunActivity(
...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }), ...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }),
...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }), ...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }),
...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }), ...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }),
...(activity.zipsBackfilled !== undefined && { zipsBackfilled: activity.zipsBackfilled }),
...(activity.currentTopicId !== undefined && { currentTopicId: activity.currentTopicId }),
...(activity.currentAccountChannelMapId !== undefined && {
currentAccountChannelMapId: activity.currentAccountChannelMapId,
}),
}, },
}); });
} }
@@ -410,6 +419,8 @@ const CLEAR_ACTIVITY = {
downloadedBytes: null, downloadedBytes: null,
totalBytes: null, totalBytes: null,
downloadPercent: null, downloadPercent: null,
currentTopicId: null,
currentAccountChannelMapId: null,
lastActivityAt: new Date(), lastActivityAt: new Date(),
}; };
@@ -420,6 +431,7 @@ export async function completeIngestionRun(
zipsFound: number; zipsFound: number;
zipsDuplicate: number; zipsDuplicate: number;
zipsIngested: number; zipsIngested: number;
zipsBackfilled: number;
} }
) { ) {
return db.ingestionRun.update({ return db.ingestionRun.update({
@@ -455,6 +467,73 @@ export async function updateLastProcessedMessage(
}); });
} }
export interface ScanStateUpdate {
/** New watermark to persist. Use the same value the caller would have
* passed to updateLastProcessedMessage / upsertTopicProgress. */
lastProcessedMessageId: bigint | null;
/** True if the scan found archives OR has retryable SkippedPackages
* pending. The caller computes this via the trulyIdle formula. */
lastScanFoundArchives: boolean;
/** Pre-incremented value of consecutiveEmptyScans. Caller passes:
* trulyIdle ? prev + 1 : 0
* We do the arithmetic outside the helper so the helper stays a pure
* setter — easier to reason about. */
consecutiveEmptyScans: number;
}
/**
* Atomically update an AccountChannelMap's watermark and scan-state fields.
* Replaces the older updateLastProcessedMessage for the post-scan write.
* Sets lastScannedAt = NOW() server-side.
*/
export async function upsertChannelScanState(
mappingId: string,
update: ScanStateUpdate
) {
return db.accountChannelMap.update({
where: { id: mappingId },
data: {
lastProcessedMessageId: update.lastProcessedMessageId ?? undefined,
lastScannedAt: new Date(),
lastScanFoundArchives: update.lastScanFoundArchives,
consecutiveEmptyScans: update.consecutiveEmptyScans,
},
});
}
/**
* Atomically upsert a TopicProgress row with the new watermark + scan-state
* fields. Same semantics as upsertChannelScanState but for forum topics.
*/
export async function upsertTopicScanState(
accountChannelMapId: string,
topicId: bigint,
topicName: string | null,
update: ScanStateUpdate
) {
return db.topicProgress.upsert({
where: {
accountChannelMapId_topicId: { accountChannelMapId, topicId },
},
create: {
accountChannelMapId,
topicId,
topicName,
lastProcessedMessageId: update.lastProcessedMessageId,
lastScannedAt: new Date(),
lastScanFoundArchives: update.lastScanFoundArchives,
consecutiveEmptyScans: update.consecutiveEmptyScans,
},
update: {
topicName,
lastProcessedMessageId: update.lastProcessedMessageId ?? undefined,
lastScannedAt: new Date(),
lastScanFoundArchives: update.lastScanFoundArchives,
consecutiveEmptyScans: update.consecutiveEmptyScans,
},
});
}
export async function markStaleRunsAsFailed() { export async function markStaleRunsAsFailed() {
return db.ingestionRun.updateMany({ return db.ingestionRun.updateMany({
where: { status: "RUNNING" }, where: { status: "RUNNING" },
@@ -592,6 +671,45 @@ export async function upsertTopicProgress(
}); });
} }
/**
* Ensure a TopicProgress row exists for every discovered topic so the admin
* UI can list and toggle them — including brand-new topics. Inserts missing
* rows only (fetchEnabled defaults to true); never overwrites watermarks,
* scan-state, or the user's fetchEnabled choice on rows that already exist.
*/
export async function ensureTopicProgressRows(
mappingId: string,
topics: { topicId: bigint; name: string | null }[]
): Promise<void> {
if (topics.length === 0) return;
await db.topicProgress.createMany({
data: topics.map((t) => ({
accountChannelMapId: mappingId,
topicId: t.topicId,
topicName: t.name,
})),
skipDuplicates: true,
});
}
/**
* Read the CURRENT per-topic fetch flag straight from the DB. Returns true
* when no row exists yet (default = enabled). Called fresh inside the topic
* loop so a mid-run toggle is honoured for topics not yet started.
*/
export async function isTopicFetchEnabled(
mappingId: string,
topicId: bigint
): Promise<boolean> {
const row = await db.topicProgress.findUnique({
where: {
accountChannelMapId_topicId: { accountChannelMapId: mappingId, topicId },
},
select: { fetchEnabled: true },
});
return row?.fetchEnabled ?? true;
}
// ── Channel fetch requests (DB-mediated communication with web app) ── // ── Channel fetch requests (DB-mediated communication with web app) ──
export async function getChannelFetchRequest(requestId: string) { export async function getChannelFetchRequest(requestId: string) {
@@ -891,3 +1009,142 @@ export async function createAutoGroup(input: {
return group.id; return group.id;
} }
// ── Provenance backfill ──
export interface PlaceholderCandidate {
id: string;
archiveType: string;
fileName: string;
fileCount: number;
fileSize: bigint;
destMessageId: bigint | null;
destMessageIds: bigint[];
destChannel: { telegramId: bigint } | null;
}
/**
* Find every placeholder Package matching name+size (oldest first). Package
* has no direct `destChannel` relation (only the scalar `destChannelId`), so
* each row's destination TelegramChannel telegramId is resolved with a
* follow-up lookup rather than a Prisma include.
*/
export async function findPlaceholderCandidates(
destChannelId: string,
fileName: string,
fileSize: bigint,
): Promise<PlaceholderCandidate[]> {
const rows = await db.package.findMany({
where: {
fileName,
fileSize,
destMessageId: { not: null },
// Placeholder provenance (spec §1): manual-upload (source == destination)
// OR rebuild record (sourceMessageId == 0 "unknown" sentinel).
OR: [
{ sourceChannelId: destChannelId },
{ sourceMessageId: 0n },
],
},
select: {
id: true, archiveType: true, fileName: true, fileCount: true, fileSize: true,
destMessageId: true, destMessageIds: true, destChannelId: true,
},
orderBy: { indexedAt: "asc" },
});
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,
}));
}
export async function findPlaceholderCandidate(
destChannelId: string,
fileName: string,
fileSize: bigint,
): Promise<PlaceholderCandidate | null> {
return (await findPlaceholderCandidates(destChannelId, fileName, fileSize))[0] ?? null;
}
export async function getPackageFileCrcs(packageId: string): Promise<(string | null)[]> {
const rows = await db.packageFile.findMany({
where: { packageId },
select: { crc32: true },
});
return rows.map((r) => r.crc32);
}
export interface BackfillProvenanceInput {
packageId: string;
destChannelId: string; // to re-check placeholder status in-txn
sourceChannelId: string;
sourceMessageId: bigint;
sourceTopicId: bigint | null;
sourceCaption: string | null;
remoteUniqueId: string | null;
creator: string | null; // always set (re-derived by caller)
entries?: FileEntry[]; // set only if candidate had fileCount === 0
previewData?: Buffer | null; // set only if provided and candidate lacks one
previewMsgId?: bigint | null;
}
export async function backfillProvenance(input: BackfillProvenanceInput): Promise<boolean> {
return db.$transaction(async (tx) => {
const current = await tx.package.findUnique({
where: { id: input.packageId },
select: { sourceChannelId: true, sourceMessageId: true, previewData: true, fileCount: true },
});
// Re-check placeholder status inside the txn (another worker may have won).
// Placeholder = manual-upload (source==dest) OR rebuild (sourceMessageId==0).
const stillPlaceholder =
!!current &&
(current.sourceChannelId === input.destChannelId || current.sourceMessageId === 0n);
if (!stillPlaceholder) return false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = {
sourceChannelId: input.sourceChannelId,
sourceMessageId: input.sourceMessageId,
sourceTopicId: input.sourceTopicId,
sourceCaption: input.sourceCaption,
remoteUniqueId: input.remoteUniqueId,
creator: input.creator,
};
if (input.entries && current.fileCount === 0) {
await tx.packageFile.deleteMany({ where: { packageId: input.packageId } });
await tx.packageFile.createMany({
data: input.entries.map((e) => ({
packageId: input.packageId,
path: e.path, fileName: e.fileName, extension: e.extension,
compressedSize: e.compressedSize, uncompressedSize: e.uncompressedSize, crc32: e.crc32,
})),
});
data.fileCount = input.entries.length;
}
if (input.previewData && !current.previewData) {
data.previewData = input.previewData;
data.previewMsgId = input.previewMsgId ?? null;
}
await tx.package.update({ where: { id: input.packageId }, data });
return true;
});
}
+306
View File
@@ -0,0 +1,306 @@
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,
getPackageFileCrcs,
backfillProvenance,
type PlaceholderCandidate,
} from "./db/queries.js";
import type { FileEntry } from "./archive/zip-reader.js";
import { readSevenZListingRanged, type RangedPart } from "./archive/ranged/sevenz-ranged.js";
import { readRarListingRanged } from "./archive/ranged/rar-ranged.js";
import { tdlibRangeReader } from "./archive/ranged/range-reader.js";
import { fullDownloadListing } from "./archive/ranged/fallback.js";
import type { Client } from "tdl";
const log = childLogger("provenance-backfill");
export interface BackfillArgs {
client: Client;
destChannelId: string;
scannedSourceChannelId: string;
fileName: string;
fileSize: bigint;
archiveType: string;
sourceMessageId: bigint;
sourceTopicId: bigint | null;
sourceCaption: string | null;
remoteUniqueId: string | null;
creator: string | null;
scannedParts: RangedPart[];
previewData?: Buffer | null;
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
* with correct per-part sizes and names (the last message carries the
* EOCD-bearing tail part for ZIP; multipart RAR needs correctly-suffixed
* `.partN.rar` names for `unrar` sibling discovery). Cheap-only: any TDLib
* failure here degrades the caller to name-size confidence rather than
* falling back to a full download.
*/
async function resolveDestParts(
client: Client,
destChatTelegramId: bigint,
destMessageIds: bigint[],
destMessageId: bigint | null,
fallbackFileName: string,
): Promise<RangedPart[] | null> {
const messageIds = destMessageIds.length > 0 ? destMessageIds : destMessageId ? [destMessageId] : [];
if (messageIds.length === 0) return null;
try {
const parts: RangedPart[] = [];
for (const msgId of messageIds) {
const msg = (await invokeWithTimeout(client, {
_: "getMessage",
chat_id: Number(destChatTelegramId),
message_id: Number(msgId),
})) as { content?: { document?: { document?: { id: number; size?: number }; file_name?: string } } };
const doc = msg?.content?.document?.document;
if (!doc?.id) return null;
const fileName = msg?.content?.document?.file_name || fallbackFileName;
parts.push({ fileId: String(doc.id), fileSize: BigInt(doc.size ?? 0), fileName });
}
return parts;
} catch (err) {
log.warn({ err, destMessageIds: messageIds.map(Number) }, "destination archive part resolution failed");
return null;
}
}
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(
client: Client,
candidate: PlaceholderCandidate,
): Promise<FileEntry[]> {
const candidateCrcs = await getPackageFileCrcs(candidate.id);
let candidateEntries: FileEntry[] = candidateCrcs.map((crc) => ({
path: "", fileName: "", extension: null, compressedSize: 0n, uncompressedSize: 0n, crc32: crc,
}));
const hasDestMessage = candidate.destMessageIds.length > 0 || candidate.destMessageId != null;
if (!crcFingerprint(candidateEntries).complete && hasDestMessage && candidate.destChannel) {
const destParts = await resolveDestParts(
client,
candidate.destChannel.telegramId,
candidate.destMessageIds,
candidate.destMessageId,
candidate.fileName,
);
let destEntries: FileEntry[] | null = null;
if (destParts) {
const read = tdlibRangeReader(client);
destEntries =
candidate.archiveType === "ZIP" ? await readScannedZipListing(client, destParts)
: candidate.archiveType === "SEVEN_Z" ? await readSevenZListingRanged(destParts, read)
: candidate.archiveType === "RAR" ? await readRarListingRanged(destParts, read)
: null;
}
if (destEntries) {
candidateEntries = destEntries;
}
}
return candidateEntries;
}
/**
* Classify a fingerprint comparison between two entry sets. "incomplete"
* means at least one side is missing CRCs (e.g. an empty file → CRC32 of
* zero-length data → null) and the comparison CANNOT be used to confirm or
* 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" {
const fa = crcFingerprint(a);
const fb = crcFingerprint(b);
if (!fa.complete || !fb.complete) return "incomplete";
return fingerprintsMatch(a, b) ? "match" : "mismatch";
}
export async function tryProvenanceBackfill(
args: BackfillArgs,
): Promise<{ backfilled: boolean; confidence?: "fingerprint" | "name-size" }> {
const candidates = await findPlaceholderCandidates(args.destChannelId, args.fileName, args.fileSize);
if (candidates.length === 0) return { backfilled: false };
let scannedEntries: FileEntry[] | null = await readScannedListingRanged(
args.archiveType, args.client, args.scannedParts,
);
// Cheap ranged read failed — fall back to a size-capped full download so the
// listing still gets indexed. Only worth it when the candidate lacks a listing.
if (!scannedEntries && candidates.some((c) => c.fileCount === 0)) {
const totalSize = args.scannedParts.reduce((s, p) => s + p.fileSize, 0n);
scannedEntries = await fullDownloadListing({
client: args.client, parts: args.scannedParts, archiveType: args.archiveType,
totalSize, fileName: args.fileName,
});
}
let chosen = candidates[0];
let confidence: "fingerprint" | "name-size" = "name-size";
if (candidates.length > 1) {
// Multiple placeholder packages share this name+size. Try to
// disambiguate by fingerprint (ZIP only); if we can't uniquely resolve
// it, notify instead of guessing which one is the real match.
if (args.archiveType === "ZIP" && scannedEntries) {
const matches: PlaceholderCandidate[] = [];
// Candidates NOT ruled out as a definite (both-complete) mismatch —
// used as the name+size fallback pool when the fingerprint can't
// confirm a match (e.g. incomplete CRCs on either side).
const nonMismatches: PlaceholderCandidate[] = [];
for (const c of candidates) {
const candidateEntries = await resolveCandidateFingerprintEntries(args.client, c);
const comparison = compareFingerprints(scannedEntries, candidateEntries);
if (comparison === "match") {
matches.push(c);
nonMismatches.push(c);
} else if (comparison === "incomplete") {
nonMismatches.push(c);
}
// comparison === "mismatch": both sides complete and differ — excluded.
}
if (matches.length === 1) {
chosen = matches[0];
confidence = "fingerprint";
} else if (matches.length === 0 && nonMismatches.length === 1) {
// Fingerprint couldn't confirm (incomplete CRCs), but exactly one
// candidate wasn't ruled out as a definite mismatch — fall back to
// name+size confidence rather than treating this as unresolved.
chosen = nonMismatches[0];
confidence = "name-size";
} else {
await db.systemNotification.create({
data: {
type: "INTEGRITY_AUDIT",
severity: "WARNING",
title: `Ambiguous provenance match: ${args.fileName}`,
message: `${candidates.length} placeholder packages share this name+size and the fingerprint did not uniquely disambiguate. No provenance was backfilled.`,
context: { fileName: args.fileName, candidateIds: candidates.map((c) => c.id) },
},
});
return { backfilled: false };
}
} else {
// Can't disambiguate without a fingerprint — notify, don't guess.
await db.systemNotification.create({
data: {
type: "INTEGRITY_AUDIT",
severity: "WARNING",
title: `Ambiguous provenance match: ${args.fileName}`,
message: `${candidates.length} placeholder packages share this name+size (archive type ${args.archiveType} — no cheap fingerprint). No provenance was backfilled.`,
context: { fileName: args.fileName, candidateIds: candidates.map((c) => c.id) },
},
});
return { backfilled: false };
}
} else if (scannedEntries) {
const candidateEntries = await resolveCandidateFingerprintEntries(args.client, chosen);
const comparison = compareFingerprints(scannedEntries, candidateEntries);
if (comparison === "match") {
confidence = "fingerprint";
} else if (comparison === "mismatch") {
// Both sides' CRCs are complete and differ: NOT the same content
// despite name+size. Do not backfill.
log.info({ candidateId: chosen.id, fileName: args.fileName }, "fingerprint mismatch — not backfilling");
return { backfilled: false };
}
// comparison === "incomplete": can't confirm or refute by fingerprint —
// fall through and backfill on name+size confidence instead.
}
const ok = await backfillProvenance({
packageId: chosen.id,
destChannelId: args.destChannelId,
sourceChannelId: args.scannedSourceChannelId,
sourceMessageId: args.sourceMessageId,
sourceTopicId: args.sourceTopicId,
sourceCaption: args.sourceCaption,
remoteUniqueId: args.remoteUniqueId,
creator: args.creator,
entries: chosen.fileCount === 0 && scannedEntries ? scannedEntries : undefined,
previewData: args.previewData ?? undefined,
previewMsgId: args.previewMsgId ?? undefined,
});
if (!ok) return { backfilled: false };
if (confidence === "name-size") {
// Lower-confidence backfill: no CRC fingerprint guard confirmed this
// match. Record it as an auditable event so name+size-only backfills
// can be reviewed after the fact.
await db.systemNotification.create({
data: {
type: "INTEGRITY_AUDIT",
severity: "INFO",
title: `Provenance backfilled by name+size: ${args.fileName}`,
message: `Package ${chosen.id} was matched to a scanned source message by file name and size only (no CRC fingerprint confirmation).`,
context: {
packageId: chosen.id,
fileName: args.fileName,
sourceChannelId: args.scannedSourceChannelId,
},
},
});
}
log.info(
{ candidateId: chosen.id, fileName: args.fileName, confidence, source: args.scannedSourceChannelId },
"provenance backfilled",
);
return { backfilled: true, confidence };
}
+6
View File
@@ -19,6 +19,12 @@ let activeCyclePromise: Promise<void> | null = null;
*/ */
const CYCLE_TIMEOUT_MS = (parseInt(process.env.WORKER_CYCLE_TIMEOUT_MINUTES ?? "240", 10)) * 60 * 1000; const CYCLE_TIMEOUT_MS = (parseInt(process.env.WORKER_CYCLE_TIMEOUT_MINUTES ?? "240", 10)) * 60 * 1000;
/** Read-only access to the current cycle counter for code that needs to
* apply per-cycle modulo logic (e.g. the cold-channel backoff). */
export function getCurrentCycle(): number {
return cycleCount;
}
/** /**
* Run one ingestion cycle: * Run one ingestion cycle:
* 1. Authenticate any PENDING accounts (triggers SMS code flow + auto-fetch channels) * 1. Authenticate any PENDING accounts (triggers SMS code flow + auto-fetch channels)
+60
View File
@@ -307,3 +307,63 @@ export async function searchPublicChat(
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
/**
* Return the chat's server-side last_message.id from TDLib's local cache.
* Used by the channel-scan-skip guard to short-circuit a paginated
* searchChatMessages when nothing has changed since our watermark.
*
* Returns null when the chat has no last_message (empty channel) or the
* call fails — callers must treat null as "unknown" and run the scan.
*/
export async function getChannelLastMessageId(
client: Client,
chatId: bigint
): Promise<bigint | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chat = (await client.invoke({
_: "getChat",
chat_id: Number(chatId),
})) as { last_message?: { id?: number } };
const id = chat.last_message?.id;
return id ? BigInt(id) : null;
} catch (err) {
log.debug({ err, chatId: chatId.toString() }, "getChannelLastMessageId failed");
return null;
}
}
/**
* Return the forum topic's last_message_id from TDLib. Same purpose as
* getChannelLastMessageId but scoped to a single topic in a forum
* supergroup. TDLib's `getForumTopic` returns a `forumTopic` whose `info`
* field contains the last_message_id.
*
* Returns null on failure or empty topic — caller treats as "unknown".
*/
export async function getForumTopicLastMessageId(
client: Client,
chatId: bigint,
topicId: bigint
): Promise<bigint | null> {
try {
// TDLib 1.8.64 uses `forum_topic_id` (renamed from `message_thread_id`
// in the request) — consistent with the rest of the forum-topic API
// surface in this version.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const topic = (await client.invoke({
_: "getForumTopic",
chat_id: Number(chatId),
forum_topic_id: Number(topicId),
})) as { last_message?: { id?: number }; info?: { last_message_id?: number } };
const id = topic.last_message?.id ?? topic.info?.last_message_id;
return id ? BigInt(id) : null;
} catch (err) {
log.debug(
{ err, chatId: chatId.toString(), topicId: topicId.toString() },
"getForumTopicLastMessageId failed"
);
return null;
}
}
+57
View File
@@ -0,0 +1,57 @@
import { open } from "fs/promises";
import type { Client } from "tdl";
import { childLogger } from "../util/logger.js";
import { withFloodWait } from "../util/retry.js";
const log = childLogger("range-download");
const RANGE_TIMEOUT_MS = 120_000;
// NOTE (from Task 4 spike): TDLib writes the requested region into
// file.local.path at its ABSOLUTE file offset; file.local.downloaded_prefix_size
// counts contiguous bytes from download_offset. We request a 1KB-aligned offset
// so downloaded_prefix_size covers our whole [offset, offset+limit) window.
// This absolute-offset assumption is PENDING LIVE VERIFICATION ON DEPLOY —
// the authenticated TDLib session could not be spiked in this environment.
export async function downloadFileRange(
client: Client,
fileId: string,
offset: number,
limit: number,
expectedSize: bigint,
): Promise<Buffer> {
const numericId = parseInt(fileId, 10);
const alignedOffset = Math.max(0, offset - (offset % 1024));
const alignedLimit = limit + (offset - alignedOffset);
const file = await withFloodWait(
() =>
new Promise<{ local: { path: string; download_offset: number; downloaded_prefix_size: number } }>(
(resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Range download timed out for ${fileId}`)), RANGE_TIMEOUT_MS);
client
.invoke({
_: "downloadFile",
file_id: numericId,
priority: 1,
offset: alignedOffset,
limit: alignedLimit,
synchronous: true,
} as never)
.then((f) => { clearTimeout(timer); resolve(f as never); })
.catch((e) => { clearTimeout(timer); reject(e); });
},
),
`downloadFileRange:${fileId}`,
);
const start = offset;
const fh = await open(file.local.path, "r");
try {
const buf = Buffer.alloc(limit);
const { bytesRead } = await fh.read(buf, 0, limit, start);
log.debug({ fileId, offset, limit, bytesRead }, "range read");
return bytesRead < limit ? buf.subarray(0, bytesRead) : buf;
} finally {
await fh.close();
}
}
+18
View File
@@ -24,4 +24,22 @@ export const config = {
* stops auto-retrying and lets the watermark advance past it. The user can * stops auto-retrying and lets the watermark advance past it. The user can
* manually retry via the UI to reset and try again. */ * manually retry via the UI to reset and try again. */
maxSkipAttempts: parseInt(process.env.WORKER_MAX_SKIP_ATTEMPTS ?? "5", 10), maxSkipAttempts: parseInt(process.env.WORKER_MAX_SKIP_ATTEMPTS ?? "5", 10),
/** Window in which a recent successful empty scan lets us skip the next
* scan entirely. Default 5 minutes. */
skipRecentScanWindowMs: parseInt(
process.env.WORKER_SKIP_RECENT_SCAN_WINDOW_MS ?? "300000",
10
),
/** After this many consecutive empty scans, a channel/topic enters
* backoff and is only scanned every Nth cycle. */
emptyScanBackoffThreshold: parseInt(
process.env.WORKER_EMPTY_SCAN_BACKOFF_THRESHOLD ?? "5",
10
),
/** While in backoff, scan only every Nth cycle. Default 5 = scan every
* fifth cycle = once every ~5 hours given the 60-min default interval. */
emptyScanBackoffEveryNth: parseInt(
process.env.WORKER_EMPTY_SCAN_BACKOFF_EVERY_NTH ?? "5",
10
),
} as const; } as const;
+434 -60
View File
@@ -35,10 +35,20 @@ import {
findPackageByRemoteUniqueId, findPackageByRemoteUniqueId,
getRetryableSkippedMessageIds, getRetryableSkippedMessageIds,
updatePackageTopicContext, updatePackageTopicContext,
upsertChannelScanState,
upsertTopicScanState,
ensureTopicProgressRows,
isTopicFetchEnabled,
} from "./db/queries.js"; } from "./db/queries.js";
import type { ActivityUpdate } from "./db/queries.js"; import type { ActivityUpdate } from "./db/queries.js";
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js"; import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
import { getAccountChats, joinChatByInviteLink } from "./tdlib/chats.js"; import {
getAccountChats,
joinChatByInviteLink,
getChannelLastMessageId,
getForumTopicLastMessageId,
} from "./tdlib/chats.js";
import { getCurrentCycle } from "./scheduler.js";
import { getChannelMessages, downloadFile, downloadPhotoThumbnail } from "./tdlib/download.js"; import { getChannelMessages, downloadFile, downloadPhotoThumbnail } from "./tdlib/download.js";
import type { DownloadProgress, ChannelScanResult } from "./tdlib/download.js"; import type { DownloadProgress, ChannelScanResult } from "./tdlib/download.js";
import { isChatForum, getForumTopicList, getTopicMessages } from "./tdlib/topics.js"; import { isChatForum, getForumTopicList, getTopicMessages } from "./tdlib/topics.js";
@@ -53,9 +63,10 @@ import { hashParts } from "./archive/hash.js";
import { readZipCentralDirectory } from "./archive/zip-reader.js"; import { readZipCentralDirectory } from "./archive/zip-reader.js";
import { readRarContents } from "./archive/rar-reader.js"; import { readRarContents } from "./archive/rar-reader.js";
import { read7zContents } from "./archive/sevenz-reader.js"; import { read7zContents } from "./archive/sevenz-reader.js";
import { tryProvenanceBackfill } from "./provenance-backfill.js";
import { byteLevelSplit, concatenateFiles } from "./archive/split.js"; import { byteLevelSplit, concatenateFiles } from "./archive/split.js";
import { uploadToChannel, UploadStallError } from "./upload/channel.js"; import { uploadToChannel, UploadStallError } from "./upload/channel.js";
import { processAlbumGroups, processRuleBasedGroups, processTimeWindowGroups, processPatternGroups, processCreatorGroups, processZipPathGroups, processReplyChainGroups, processCaptionGroups, detectGroupingConflicts, type IndexedPackageRef } from "./grouping.js"; import { processAlbumGroups, detectGroupingConflicts, type IndexedPackageRef } from "./grouping.js";
import { db } from "./db/client.js"; import { db } from "./db/client.js";
import type { TelegramAccount, TelegramChannel } from "@prisma/client"; import type { TelegramAccount, TelegramChannel } from "@prisma/client";
import type { Client } from "tdl"; import type { Client } from "tdl";
@@ -304,6 +315,7 @@ interface PipelineContext {
zipsFound: number; zipsFound: number;
zipsDuplicate: number; zipsDuplicate: number;
zipsIngested: number; zipsIngested: number;
zipsBackfilled: number;
}; };
/** Creator from forum topic name (null for non-forum). */ /** Creator from forum topic name (null for non-forum). */
topicCreator: string | null; topicCreator: string | null;
@@ -412,6 +424,7 @@ export async function runWorkerForAccount(
zipsFound: 0, zipsFound: 0,
zipsDuplicate: 0, zipsDuplicate: 0,
zipsIngested: 0, zipsIngested: 0,
zipsBackfilled: 0,
}; };
try { try {
@@ -528,6 +541,8 @@ export async function runWorkerForAccount(
currentActivity: `Enumerating topics in "${channelLabel}"`, currentActivity: `Enumerating topics in "${channelLabel}"`,
currentStep: "scanning", currentStep: "scanning",
currentChannel: channelLabel, currentChannel: channelLabel,
currentTopicId: null,
currentAccountChannelMapId: null,
currentFile: null, currentFile: null,
currentFileNum: null, currentFileNum: null,
totalFiles: null, totalFiles: null,
@@ -540,6 +555,15 @@ export async function runWorkerForAccount(
const rawTopics = await getForumTopicList(client, channel.telegramId); const rawTopics = await getForumTopicList(client, channel.telegramId);
const topicProgressList = await getTopicProgress(mapping.id); const topicProgressList = await getTopicProgress(mapping.id);
// Persist a TopicProgress row for every discovered topic so the
// admin UI can list and toggle them — including brand-new topics.
// Inserts missing rows only; existing watermarks / scan-state /
// fetchEnabled choices are left untouched.
await ensureTopicProgressRows(
mapping.id,
rawTopics.map((t) => ({ topicId: t.topicId, name: t.name }))
);
// Process more-specific topics BEFORE "General" so the first // Process more-specific topics BEFORE "General" so the first
// encounter of any file is in its most specific context. This makes // encounter of any file is in its most specific context. This makes
// newly-created Packages carry useful topic info (e.g., a campaign // newly-created Packages carry useful topic info (e.g., a campaign
@@ -559,10 +583,92 @@ export async function runWorkerForAccount(
for (let tIdx = 0; tIdx < topics.length; tIdx++) { for (let tIdx = 0; tIdx < topics.length; tIdx++) {
const topic = topics[tIdx]; const topic = topics[tIdx];
try { try {
// ── Per-topic fetch toggle (live, mid-run honouring) ──
// Read the CURRENT enabled flag straight from the DB (not the
// run-start `topicProgressList` snapshot) so disabling a topic
// mid-run skips it for the remainder of this run.
if (!(await isTopicFetchEnabled(mapping.id, topic.topicId))) {
accountLog.info(
{ channel: channel.title, topic: topic.name },
"Topic fetch disabled by user — skipping"
);
continue;
}
let progress = topicProgressList.find( let progress = topicProgressList.find(
(tp) => tp.topicId === topic.topicId (tp) => tp.topicId === topic.topicId
); );
// ── General-topic ID migration ──
// TDLib 1.8.50 reported `info.message_thread_id = 1048576` for
// the General topic (a magic constant). TDLib 1.8.64 reports
// `info.forum_topic_id = 1` for the same topic. Old DB rows
// therefore don't match the new numeric ID — fall back to a
// name match so we don't restart General from message 0. On
// the next watermark write, we'll save under the new ID and
// future runs hit the topicId match directly. The orphaned
// 1048576 row remains as harmless dead data.
if (!progress && topic.name === "General") {
const oldGeneral = topicProgressList.find(
(tp) => tp.topicName === "General" && tp.topicId !== topic.topicId
);
if (oldGeneral) {
accountLog.info(
{
channel: channel.title,
oldTopicId: oldGeneral.topicId.toString(),
newTopicId: topic.topicId.toString(),
preservedWatermark: oldGeneral.lastProcessedMessageId?.toString() ?? null,
},
"Reusing old General-topic progress under new TDLib forum_topic_id"
);
progress = oldGeneral;
}
}
// ── Topic-scan-skip guard ──
// Same three-signal decision as the non-forum branch, but
// scoped to a single topic. Uses `progress` for the persisted
// scan-state fields (lastScannedAt etc).
try {
const retryableForTopic = await getRetryableSkippedMessageIds({
accountId: account.id,
sourceChannelId: channel.id,
topicId: topic.topicId,
cap: config.maxSkipAttempts,
});
if (retryableForTopic.length === 0 && progress?.lastScannedAt) {
const sinceLastScanMs = Date.now() - progress.lastScannedAt.getTime();
const withinRecencyWindow = sinceLastScanMs < config.skipRecentScanWindowMs;
const inBackoff =
(progress.consecutiveEmptyScans ?? 0) >= config.emptyScanBackoffThreshold;
const backoffSkipsThisCycle =
inBackoff && getCurrentCycle() % config.emptyScanBackoffEveryNth !== 0;
if (
(withinRecencyWindow && !progress.lastScanFoundArchives) ||
backoffSkipsThisCycle
) {
accountLog.debug(
{
channel: channel.title,
topic: topic.name,
sinceLastScanMs,
consecutiveEmptyScans: progress.consecutiveEmptyScans,
reason: withinRecencyWindow ? "recent-idle" : "backoff",
},
"Skipping topic — recently scanned and idle, or in backoff"
);
continue;
}
}
} catch (skipErr) {
accountLog.warn(
{ err: skipErr, topic: topic.name },
"Topic skip guard failed, proceeding with scan"
);
}
// ── SkippedPackage retry pass ── // ── SkippedPackage retry pass ──
// If we have failed messages in this topic with attemptCount // If we have failed messages in this topic with attemptCount
// below the cap, pull the watermark back below the lowest of // below the cap, pull the watermark back below the lowest of
@@ -613,10 +719,44 @@ export async function runWorkerForAccount(
? ` (topic ${tIdx + 1}/${topics.length})` ? ` (topic ${tIdx + 1}/${topics.length})`
: ""; : "";
// ── getForumTopic short-circuit ──
// After the retry pass has settled the effective watermark,
// ask TDLib for the topic's last_message_id. If it's <= our
// watermark, no new content — skip the paginated search.
const topicLastId = await getForumTopicLastMessageId(
client,
channel.telegramId,
topic.topicId
);
const effectiveTopicWatermark = progress?.lastProcessedMessageId ?? null;
if (
topicLastId !== null
&& effectiveTopicWatermark !== null
&& topicLastId <= effectiveTopicWatermark
) {
accountLog.info(
{
channel: channel.title,
topic: topic.name,
topicLastId: topicLastId.toString(),
watermark: effectiveTopicWatermark.toString(),
},
"Topic caught up via getForumTopic — skipping searchChatMessages"
);
await upsertTopicScanState(mapping.id, topic.topicId, topic.name, {
lastProcessedMessageId: effectiveTopicWatermark,
lastScanFoundArchives: false,
consecutiveEmptyScans: (progress?.consecutiveEmptyScans ?? 0) + 1,
});
continue;
}
await updateRunActivity(activeRunId, { await updateRunActivity(activeRunId, {
currentActivity: `Scanning "${topicLabel}"${topicProgress}`, currentActivity: `Scanning "${topicLabel}"${topicProgress}`,
currentStep: "scanning", currentStep: "scanning",
currentChannel: channelLabel, currentChannel: channelLabel,
currentTopicId: topic.topicId,
currentAccountChannelMapId: mapping.id,
currentFile: null, currentFile: null,
currentFileNum: null, currentFileNum: null,
totalFiles: null, totalFiles: null,
@@ -650,14 +790,25 @@ export async function runWorkerForAccount(
{ channelId: channel.id, topic: topic.name, totalScanned: scanResult.totalScanned }, { channelId: channel.id, topic: topic.name, totalScanned: scanResult.totalScanned },
"No new archives in topic" "No new archives in topic"
); );
// Still advance topic watermark so we don't re-scan these messages next cycle // Still advance topic watermark so we don't re-scan these
// messages next cycle. Truly idle only when no retryable
// SkippedPackages are pending for this topic — chronically-
// failing archives must NOT push a topic into backoff.
const retryableTopicNoArchives = await getRetryableSkippedMessageIds({
accountId: account.id,
sourceChannelId: channel.id,
topicId: topic.topicId,
cap: config.maxSkipAttempts,
});
const topicTrulyIdleNoArchives = retryableTopicNoArchives.length === 0;
if (scanResult.maxScannedMessageId) { if (scanResult.maxScannedMessageId) {
await upsertTopicProgress( await upsertTopicScanState(mapping.id, topic.topicId, topic.name, {
mapping.id, lastProcessedMessageId: scanResult.maxScannedMessageId,
topic.topicId, lastScanFoundArchives: !topicTrulyIdleNoArchives,
topic.name, consecutiveEmptyScans: topicTrulyIdleNoArchives
scanResult.maxScannedMessageId ? (progress?.consecutiveEmptyScans ?? 0) + 1
); : 0,
});
} }
continue; continue;
} }
@@ -686,7 +837,11 @@ export async function runWorkerForAccount(
topic.name, topic.name,
messageId messageId
); );
} },
// shouldStop: re-read the live fetch flag before each archive
// set. A mid-run "disable topic" lets the current file finish,
// then skips the rest of this topic's archives.
async () => !(await isTopicFetchEnabled(mapping.id, topic.topicId))
); );
// Sync client back in case it was recreated during upload stall recovery // Sync client back in case it was recreated during upload stall recovery
client = pipelineCtx.client; client = pipelineCtx.client;
@@ -698,13 +853,27 @@ export async function runWorkerForAccount(
if (minFailedId !== null && topicWatermark !== null && topicWatermark >= minFailedId) { if (minFailedId !== null && topicWatermark !== null && topicWatermark >= minFailedId) {
topicWatermark = minFailedId - 1n; topicWatermark = minFailedId - 1n;
} }
// trulyIdle: no archives this scan AND no failures AND no
// retryable pending. Same definition as the non-forum branch.
const retryableTopicPendingNow = await getRetryableSkippedMessageIds({
accountId: account.id,
sourceChannelId: channel.id,
topicId: topic.topicId,
cap: config.maxSkipAttempts,
});
const topicTrulyIdle =
scanResult.archives.length === 0
&& minFailedId === null
&& retryableTopicPendingNow.length === 0;
const newTopicConsecutive = topicTrulyIdle
? (progress?.consecutiveEmptyScans ?? 0) + 1
: 0;
if (topicWatermark !== null) { if (topicWatermark !== null) {
await upsertTopicProgress( await upsertTopicScanState(mapping.id, topic.topicId, topic.name, {
mapping.id, lastProcessedMessageId: topicWatermark,
topic.topicId, lastScanFoundArchives: !topicTrulyIdle,
topic.name, consecutiveEmptyScans: newTopicConsecutive,
topicWatermark });
);
} }
} catch (topicErr) { } catch (topicErr) {
accountLog.warn( accountLog.warn(
@@ -714,11 +883,58 @@ export async function runWorkerForAccount(
} }
} }
} else { } else {
// ── Channel-scan-skip guard ──
// Before any TDLib call, decide whether this channel can be
// skipped entirely this cycle. Three signals (in order):
// 1. retryable SkippedPackages exist → MUST scan
// 2. lastScannedAt within window AND last scan was idle → skip
// 3. in backoff AND not the Nth cycle → skip
try {
const retryable = await getRetryableSkippedMessageIds({
accountId: account.id,
sourceChannelId: channel.id,
topicId: null,
cap: config.maxSkipAttempts,
});
if (retryable.length === 0 && mapping.lastScannedAt) {
const sinceLastScanMs = Date.now() - mapping.lastScannedAt.getTime();
const withinRecencyWindow = sinceLastScanMs < config.skipRecentScanWindowMs;
const inBackoff = mapping.consecutiveEmptyScans >= config.emptyScanBackoffThreshold;
const backoffSkipsThisCycle =
inBackoff && getCurrentCycle() % config.emptyScanBackoffEveryNth !== 0;
if (
(withinRecencyWindow && !mapping.lastScanFoundArchives) ||
backoffSkipsThisCycle
) {
accountLog.debug(
{
channel: channel.title,
sinceLastScanMs,
consecutiveEmptyScans: mapping.consecutiveEmptyScans,
reason: withinRecencyWindow ? "recent-idle" : "backoff",
},
"Skipping channel — recently scanned and idle, or in backoff"
);
continue;
}
}
} catch (skipErr) {
// Skip guard is best-effort. If the retryable query fails,
// fall through and do the normal scan.
accountLog.warn(
{ err: skipErr, channel: channel.title },
"Skip guard failed, proceeding with scan"
);
}
// ── Non-forum channel: flat scan (existing behavior) ── // ── Non-forum channel: flat scan (existing behavior) ──
await updateRunActivity(activeRunId, { await updateRunActivity(activeRunId, {
currentActivity: `Scanning "${channelLabel}" for new archives`, currentActivity: `Scanning "${channelLabel}" for new archives`,
currentStep: "scanning", currentStep: "scanning",
currentChannel: channelLabel, currentChannel: channelLabel,
currentTopicId: null,
currentAccountChannelMapId: null,
currentFile: null, currentFile: null,
currentFileNum: null, currentFileNum: null,
totalFiles: null, totalFiles: null,
@@ -770,6 +986,34 @@ export async function runWorkerForAccount(
); );
} }
// ── getChat short-circuit ──
// After the retry pass has settled the effective watermark, ask
// TDLib for the channel's last_message.id. If it's <= our watermark,
// no new content exists since last cycle — skip the paginated
// searchChatMessages entirely. Still update scan-state so the
// recent-scan skip can kick in next cycle.
const channelLastId = await getChannelLastMessageId(client, channel.telegramId);
if (
channelLastId !== null
&& effectiveChannelWatermark !== null
&& channelLastId <= effectiveChannelWatermark
) {
accountLog.info(
{
channel: channel.title,
channelLastId: channelLastId.toString(),
watermark: effectiveChannelWatermark.toString(),
},
"Channel caught up via getChat — skipping searchChatMessages"
);
await upsertChannelScanState(mapping.id, {
lastProcessedMessageId: effectiveChannelWatermark,
lastScanFoundArchives: false,
consecutiveEmptyScans: (mapping.consecutiveEmptyScans ?? 0) + 1,
});
continue;
}
const scanResult = await getChannelMessages( const scanResult = await getChannelMessages(
client, client,
channel.telegramId, channel.telegramId,
@@ -790,10 +1034,24 @@ export async function runWorkerForAccount(
if (scanResult.archives.length === 0) { if (scanResult.archives.length === 0) {
accountLog.info({ channelId: channel.id, title: channel.title, totalScanned: scanResult.totalScanned }, "No new archives in channel"); accountLog.info({ channelId: channel.id, title: channel.title, totalScanned: scanResult.totalScanned }, "No new archives in channel");
// Still advance watermark to highest scanned message so we don't // Truly idle requires no retryable SkippedPackages — a channel
// re-scan these messages next cycle // with a chronically-failing archive must NOT enter backoff just
// because no NEW archives showed up this scan.
const retryableNoArchives = await getRetryableSkippedMessageIds({
accountId: account.id,
sourceChannelId: channel.id,
topicId: null,
cap: config.maxSkipAttempts,
});
const channelTrulyIdleNoArchives = retryableNoArchives.length === 0;
if (scanResult.maxScannedMessageId) { if (scanResult.maxScannedMessageId) {
await updateLastProcessedMessage(mapping.id, scanResult.maxScannedMessageId); await upsertChannelScanState(mapping.id, {
lastProcessedMessageId: scanResult.maxScannedMessageId,
lastScanFoundArchives: !channelTrulyIdleNoArchives,
consecutiveEmptyScans: channelTrulyIdleNoArchives
? (mapping.consecutiveEmptyScans ?? 0) + 1
: 0,
});
} }
continue; continue;
} }
@@ -829,8 +1087,29 @@ export async function runWorkerForAccount(
if (minFailedId !== null && channelWatermark !== null && channelWatermark >= minFailedId) { if (minFailedId !== null && channelWatermark !== null && channelWatermark >= minFailedId) {
channelWatermark = minFailedId - 1n; channelWatermark = minFailedId - 1n;
} }
// trulyIdle: nothing new this scan AND nothing failed AND no
// retryable SkippedPackages pending. The retryable check matters —
// a chronically-failing archive should NEVER let the channel back
// off, even though zipsFound stays at 0 for it.
const retryablePendingNow = await getRetryableSkippedMessageIds({
accountId: account.id,
sourceChannelId: channel.id,
topicId: null,
cap: config.maxSkipAttempts,
});
const trulyIdle =
scanResult.archives.length === 0
&& minFailedId === null
&& retryablePendingNow.length === 0;
const newConsecutive = trulyIdle
? (mapping.consecutiveEmptyScans ?? 0) + 1
: 0;
if (channelWatermark !== null) { if (channelWatermark !== null) {
await updateLastProcessedMessage(mapping.id, channelWatermark); await upsertChannelScanState(mapping.id, {
lastProcessedMessageId: channelWatermark,
lastScanFoundArchives: !trulyIdle,
consecutiveEmptyScans: newConsecutive,
});
} }
} }
} catch (channelErr) { } catch (channelErr) {
@@ -937,7 +1216,12 @@ async function processArchiveSets(
* below any failed message ID in this scan). Used by the caller to * below any failed message ID in this scan). Used by the caller to
* advance the channel/topic watermark incrementally — otherwise a long * advance the channel/topic watermark incrementally — otherwise a long
* scan that gets killed by worker restart loses all progress. */ * scan that gets killed by worker restart loses all progress. */
onWatermarkAdvance?: (messageId: bigint) => Promise<void> onWatermarkAdvance?: (messageId: bigint) => Promise<void>,
/** Optional cancellation check, polled before each archive set. When it
* resolves true, processing stops after the set currently in flight (that
* one completes; remaining sets in this scan are skipped). Used by the
* forum branch to honour a mid-run "disable topic". */
shouldStop?: () => Promise<boolean>
): Promise<{ maxProcessedId: bigint | null; minFailedId: bigint | null }> { ): Promise<{ maxProcessedId: bigint | null; minFailedId: bigint | null }> {
const { client, runId, channelTitle, channel, throttled, counters, accountLog } = ctx; const { client, runId, channelTitle, channel, throttled, counters, accountLog } = ctx;
@@ -1015,6 +1299,16 @@ async function processArchiveSets(
const indexedPackageRefs: IndexedPackageRef[] = []; const indexedPackageRefs: IndexedPackageRef[] = [];
for (let setIdx = 0; setIdx < archiveSets.length; setIdx++) { for (let setIdx = 0; setIdx < archiveSets.length; setIdx++) {
// Cooperative cancellation: if the caller signals stop (e.g. the topic was
// disabled mid-run), skip the remaining archive sets in this scan. The set
// processed in the previous iteration has already completed.
if (shouldStop && (await shouldStop())) {
accountLog.info(
{ channel: channelTitle, processed: setIdx, total: archiveSets.length },
"Stop signal received (topic disabled) — skipping remaining archive sets in this scan"
);
break;
}
try { try {
const packageId = await processOneArchiveSet( const packageId = await processOneArchiveSet(
ctx, ctx,
@@ -1188,34 +1482,13 @@ async function processArchiveSets(
scanResult.photos scanResult.photos
); );
// Auto-grouping passes (gated by per-channel flag) // Heuristic auto-grouping passes (rule/time/pattern/creator/zip-path/
const channelRecord = await db.telegramChannel.findUnique({ // reply-chain/caption) were removed: the STL view is now a flat list
where: { id: channel.id }, // organized by the creator filter, so automatically inventing groups at
select: { autoGroupEnabled: true }, // ingestion is no longer wanted. Album grouping above is kept because it
}); // reflects real upload structure (files posted together as one Telegram
// album), not a heuristic guess. Existing groups and the manual grouping
if (channelRecord?.autoGroupEnabled !== false) { // actions in the UI are unaffected.
// Learned rule-based grouping (from manual overrides)
await processRuleBasedGroups(channel.id, indexedPackageRefs);
// Time-window grouping for remaining ungrouped packages
await processTimeWindowGroups(channel.id, indexedPackageRefs);
// Pattern-based grouping (date patterns, project slugs)
await processPatternGroups(channel.id, indexedPackageRefs);
// Creator-based grouping (3+ files from same creator)
await processCreatorGroups(channel.id, indexedPackageRefs);
// ZIP path prefix grouping (shared root folder inside archives)
await processZipPathGroups(channel.id, indexedPackageRefs);
// Reply chain grouping (messages replying to same root)
await processReplyChainGroups(channel.id, indexedPackageRefs);
// Caption fuzzy match grouping
await processCaptionGroups(channel.id, indexedPackageRefs);
}
// Check for potential grouping conflicts // Check for potential grouping conflicts
await detectGroupingConflicts(channel.id, indexedPackageRefs); await detectGroupingConflicts(channel.id, indexedPackageRefs);
@@ -1375,6 +1648,56 @@ async function processOneArchiveSet(
return null; return null;
} }
// ── Cross-channel provenance backfill ──
// The same-channel checks above missed. Before downloading, see if this
// archive is the true origin of a placeholder-source package (manual upload
// / rebuild record whose sourceChannelId == destChannelId). If so, backfill
// its real provenance and skip the download entirely.
const archType = archiveSet.type === "7Z" ? "SEVEN_Z" : archiveSet.type;
if (destChannelId && (archType === "ZIP" || archType === "RAR" || archType === "SEVEN_Z")) {
try {
const derivedCreator =
topicCreator && topicCreator !== "General"
? topicCreator
: (extractCreatorFromFileName(archiveName) ?? topicCreator ?? null);
const preview = previewMatches.get(archiveSet.baseName);
const result = await tryProvenanceBackfill({
client,
destChannelId,
scannedSourceChannelId: channel.id,
fileName: archiveName,
fileSize: totalArchiveSize,
archiveType: archType,
sourceMessageId: archiveSet.parts[0].id,
sourceTopicId,
sourceCaption: archiveSet.parts[0].caption ?? null,
remoteUniqueId: archiveSet.parts[0].remoteUniqueId ?? null,
creator: derivedCreator,
scannedParts: archiveSet.parts.map((p) => ({ fileId: p.fileId, fileSize: p.fileSize, fileName: p.fileName })),
previewData: null,
previewMsgId: preview?.id ?? null,
});
if (result.backfilled) {
counters.zipsBackfilled++;
accountLog.info(
{ fileName: archiveName, sourceMessageId: Number(archiveSet.parts[0].id), confidence: result.confidence },
"Backfilled provenance for placeholder package — skipping download",
);
await updateRunActivity(runId, {
currentActivity: `Backfilled provenance for ${archiveName}`,
currentStep: "backfilling",
currentFile: archiveName,
currentFileNum: setIdx + 1,
totalFiles: totalSets,
zipsBackfilled: counters.zipsBackfilled,
});
return null;
}
} catch (err) {
accountLog.warn({ err, fileName: archiveName }, "Provenance backfill attempt failed (non-fatal), continuing to normal ingestion");
}
}
// ── Size guard: skip archives that exceed WORKER_MAX_ZIP_SIZE_MB ── // ── Size guard: skip archives that exceed WORKER_MAX_ZIP_SIZE_MB ──
const maxSizeBytes = BigInt(config.maxZipSizeMB) * 1024n * 1024n; const maxSizeBytes = BigInt(config.maxZipSizeMB) * 1024n * 1024n;
if (totalArchiveSize > maxSizeBytes) { if (totalArchiveSize > maxSizeBytes) {
@@ -1654,17 +1977,68 @@ async function processOneArchiveSet(
); );
} }
// ── Pre-upload integrity test ── // ── Pre-upload integrity test (advisory) ──
// Catch broken/encrypted archives before we burn upload bandwidth on // Run unzip -t / unrar t / 7z t to look for corruption or encryption
// them. Cheap (unzip -t / unrar t / 7z t) compared to a multi-GB upload. // before we upload. This is ADVISORY only — failures get logged and
// Skipped when we're reusing an existing upload — no point testing the // emit a SystemNotification but never block upload, because:
// file again. //
const integrity = await testArchiveIntegrity( // 1. Multipart ZIPs (`.zip.001`, `.zip.002`, ...) aren't testable
archiveSet.type === "7Z" ? "SEVEN_Z" : archiveSet.type, // chunk-by-chunk. Skip them entirely.
uploadPaths[0] // 2. Large 7z archives can OOM-kill `7z t` (exit 137) during
); // decompression on memory-limited containers — that's a tool
if (!integrity.ok) { // limitation, not actual corruption.
throw new Error(`Archive integrity check failed: ${integrity.reason}`); // 3. p7zip can fail with unhelpful messages on newer 7z features.
//
// Hash verification + archive metadata parse already cover byte-level
// corruption and structural readability. The integrity test is a
// nice-to-have stronger signal; not worth losing uploads over false
// positives.
const archType = archiveSet.type === "7Z" ? "SEVEN_Z" : archiveSet.type;
const isMultipartZip = archType === "ZIP" && tempPaths.length > 1;
if (!isMultipartZip) {
const integrity = await testArchiveIntegrity(archType, tempPaths[0]);
if (!integrity.ok) {
if (integrity.kind === "inconclusive") {
// The test tool was killed (OOM) or timed out — typically a large
// 7z in a memory-limited container. This is a tool limitation, NOT
// corruption, so log quietly and DON'T raise a notification. The
// upload proceeds exactly as before.
accountLog.debug(
{ fileName: archiveName, reason: integrity.reason.slice(0, 200) },
"Archive integrity test inconclusive — proceeding with upload (advisory check)"
);
} else {
// Encrypted (won't extract for users) or genuinely corrupt — surface
// clearly via notification but STILL proceed: the user can audit and
// decide what to do.
const isEncrypted = integrity.kind === "encrypted";
accountLog.warn(
{ fileName: archiveName, reason: integrity.reason.slice(0, 200), kind: integrity.kind },
"Archive integrity test failed — proceeding with upload anyway (advisory check)"
);
try {
await db.systemNotification.create({
data: {
type: isEncrypted ? "UPLOAD_FAILED" : "INTEGRITY_AUDIT",
severity: "WARNING",
title: isEncrypted
? `Archive may be encrypted: ${archiveName}`
: `Integrity test reported issues: ${archiveName}`,
message: integrity.reason.slice(0, 1000),
context: {
fileName: archiveName,
sourceChannelId: channel.id,
sourceMessageId: Number(archiveSet.parts[0].id),
archiveType: archType,
advisory: true,
},
},
});
} catch {
// Best-effort notification
}
}
}
} }
// ── Uploading ── // ── Uploading ──
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});