v8.0.0
Breaking
- One archive can now hold more than one Telegram account, and the database is rewritten once to make that safe. Every chat, message, media row, sync cursor, forum topic and folder is now keyed by the account that captured it. Migration
022performs the rewrite in a single transaction on both SQLite and PostgreSQL; your existing history becomes account 1, and nothing on disk moves — media stays exactly where it is. The keys had to change rather than gain a decorative column, because a chat id, a message id, a topic id and a folder id are each only unique within one account: a second account's edit history hashed identically to the first's and was silently discarded, its folders (which every account numbers from 2) took over the first account's membership rows, and its copy of a shared message read the first account's "outgoing" flag. Read docs/UPGRADING-8.0.md before you upgrade, and take a backup — that guide exists because this is the one release where the way back is a restore. (#302) - Downgrading past migration
022is refused, deliberately. Once a second account exists the old keys cannot identify a row: two messages that differ only by account collapse onto one key, so a reversal would have to choose whose copy of every shared message, folder and edit history to destroy. An interrupted upgrade needs no rollback at all — the migration is one transaction, so a failure leaves a byte-for-byte intact 7.x database that the previous image still runs. After a successful upgrade the way back is the backup taken before it, which is the only honest answer and the reason the guide asks for one first. (#302) - Every viewer URL changes shape, so existing bookmarks and share links stop working. Chat-scoped routes, the WebSocket protocol, media, thumbnails and avatars now address a chat by an opaque 22-character ref minted per chat, instead of by its Telegram chat id — so no chat id reaches a URL, the browser history or a reverse proxy's access log. One dependency resolves the ref and enforces access in a single query, replacing ten per-route permission preambles: an unknown ref, a malformed one and a chat you are not allowed to see all answer an identical 404, so nothing distinguishes them. Open the chat from the sidebar and bookmark it again; share links must be minted again. The archive itself is untouched. (#304)
- Admin writes now carry
allowed_accountsandallowed_chat_refsin place ofallowed_chat_ids. A write still sending the old field is rejected with a 400 that names the replacement, rather than being reinterpreted — an unconverted[123, 456]read under the new meaning would be "account 123, chat 456" and would grant access rather than deny it. The rules are:nullmeans unrestricted, a list means exactly those and nothing else, and anything unparseable means nothing at all. Migration022converts every restricted viewer, session and share token as part of the rewrite, so existing grants keep working without being re-entered. The 7.xallowed_chat_idscolumn is never read as a grant again; 8.0 keeps writing a deny-only marker into it purely so a rollback to a 7.x image cannot widen anyone's access. (#302, #304) POST /api/push/subscribetakeschat_refwhere it tookchat_id. The browser resubscribes on its own the next time you open the viewer; a subscription made by your own script needs the field renamed. (#304)
Added
- One archive, many Telegram accounts. Accounts are declared as indexed environment variables (
TG_ACCOUNT_1_*,TG_ACCOUNT_2_*, …), each with its own session file, and the scheduled sweep runs them one after another rather than at once — two accounts on one connection is how you get rate-limited on both. Each account keeps its own chats, its own sync cursors and its own folder numbering, and the viewer serves every account a login is entitled to see. Upgrading needs no configuration at all: if no indexed account is declared, account 1 is synthesized from theTELEGRAM_API_ID/TELEGRAM_API_HASH/TELEGRAM_PHONEyou already have, and it adopts the session you already logged in with — so a single-account install keeps running exactly as before, without a re-login. Account rows are owned by the Telegram user id, not the variable position, so re-ordering the variables can never split or steal an account's data. (#302, #305) - Search is dramatically faster on PostgreSQL. A trigram GIN index (
pg_trgm) backs message search, so a substring query stops scanning the whole table. PostgreSQL only — SQLite search is unchanged — and migration023creates both the extension and the index. Contributed by @jordanfelle. (#301)
Fixed
- An initial backup of an account with many chats can now finish.
get_dialogs()pages internally in chunks of about a hundred, and with the app-wide flood threshold at zero a rate limit on any single page aborted the entire call — after which the retry restarted pagination from page one, re-walked every page that had already succeeded, and tripped the same later page again, every run forever. Floods are now absorbed in place up toDIALOG_FLOOD_SLEEP_THRESHOLDseconds (default 60) and the same page resumes, which is what already fixed the equivalent failure for media downloads. Verified against a production account of ~1,900 dialogs whose backup had never once completed. Contributed by @jordanfelle. (#295, #296) - One unreadable attachment no longer freezes a chat's backup forever. Telegram returns an empty document for a file it can no longer retrieve; that object is truthy but carries no attributes, so walking it raised an error which aborted the whole dialog in the scheduled sweep and dropped the message outright — text included — in the real-time listener. Because the cursor is checkpointed before the offending message, every later run resumed at the same message and failed identically, while the run still reported "Backup completed successfully". An unusable document reference is now treated exactly like a missing one, in all five places that walked it. (#283)
- A single message that cannot be processed no longer stalls its chat, and is never skipped in silence. Per-message failures are contained and counted instead of aborting the dialog, and the cursor is held at the first failed id so it can never checkpoint past unprocessed history. A message that has failed on two separate runs is then passed over, and the ids passed over are recorded durably, in the existing metadata table — so the freeze has an exit and the skip leaves a trace. The first failure still behaves exactly as before, so a transient error keeps its retry. Measured before the fix, one chat's runs kept growing — 10, 8, 10, 12, 14, 16 messages re-fetched — while the cursor never moved. (#286, #292)
- The record of a passed-over message is now written before the cursor is allowed to move past it. The write could fail — it had no retry for a locked database while the listener writes the same file — and the failure was swallowed while the cursor advanced anyway, recreating the exact silent skip the record exists to prevent, under a warning claiming the ids had been kept. Metadata reads and writes now retry lock contention like the sync cursor does, the give-up branch advances only once the record has actually landed, and the frozen half of the record is cleared when its message finally succeeds. (#297)
- A link in a message now goes to the host it displays. Message text was escaped and then decoded again while building the link, and the browser decoded it a second time, so a character reference in the original text became a real character in the target: a message reading
https://good.example@evil.example/rendered as a link to "good.example" while resolving toevil.example, because@became@and demoted the visible host to a username. Anyone who could message the archived account could plant one. The escape-then-decode round trip is gone rather than patched — the raw text is split on the URL pattern and each piece escaped exactly once — so there is no decode step left to exploit. Verified in a real browser across 36 payloads. (#291) - The viewer's access control can no longer be bypassed, on either transport. The WebSocket route resolved who you were differently from the HTTP routes, so with proxy auth and password auth both enabled a socket with no credentials was accepted with no chat restrictions at all, while a genuine restricted viewer got none of its own. Both now resolve through one shared resolver. The thumbnail route authorized the raw request string but resolved a different path from it, so a percent-encoded
..read media from chats the viewer may not see; both media routes now check and normalize once, up front, so the string that is authorized is the string that selects the file. (#290) - An archived HTML or SVG attachment can no longer run as a script against your viewer session. Only images, video, audio and PDFs are still served inline; everything else downloads. Access-controlled media and thumbnails are no longer marked cacheable by shared proxies, and viewers who may not download are no longer handed thumbnail URLs that refuse them. (#290)
- A crafted image can no longer exhaust the viewer's or the archiver's memory. Thumbnail generation is gated on decoded pixel count rather than file size, so a file that is small on disk and enormous once decoded — which any Telegram contact can send — is refused. The gate is format-aware, so large JPEGs still produce thumbnails, and it now covers the video lane, where a crafted file renamed with a video extension previously skipped the check entirely. The same gate was added to the pre-generated thumbnails in the backup process. Thumbnails are written to a temp file and atomically replaced, so a concurrent reader can never cache a torn image, and a failed video thumbnail is remembered instead of being retried on every request. (#286, #287)
- One unreachable push endpoint no longer stalls the viewer for everyone. The web-push fan-out runs off the event loop, concurrently, with a real timeout. Password, token and share-token hashing moved off the event loop too, and avatar lookup no longer scans a directory there, so one request can no longer freeze every other request and WebSocket frame. A broadcast now iterates a snapshot, so a client connecting or disconnecting mid-send no longer aborts delivery to everyone else. (#287, #290)
- Re-importing a Telegram Desktop export over an already-archived chat no longer erases what was captured. The upsert set every column, so an
import --mergeNULLed the ones the importer did not supply: reply and topic pointers, album grouping, migration markers and forward provenance. The importer also no longer asserts values it cannot know, so a re-import preserves the captured ones while a fresh import still gets correct defaults. (#288) - Opening the chat list, the media gallery or a forum topic list stops scanning whole histories.
/api/chatsno longer aggregates the entire messages table on every request. Chat and message pagination now order by a total key, so rows can no longer repeat or vanish between pages, and the second, unused media read per message page is gone. (#288) - A failed login is always recorded. Values are clamped to their column widths and NUL bytes replaced, so a crafted username can no longer make the audit insert fail silently on PostgreSQL. (#288)
- Typing in a search box no longer fires a full-chat scan on every keystroke. The chat-header and message search boxes debounce and cancel superseded requests. Media-gallery, topic-list, statistics and pinned-message loads are all guarded against stale responses, so a slow answer that outlives the chat you were in no longer paints the previous chat's data — counts, pinned banner and all — into the new one. (#289, #298)
- Clicking a notification always does something. The service worker focuses an open tab when it can and otherwise opens the deep link, instead of relying on a message channel that may not be listening yet. (#289)
- The viewer no longer creates database tables underneath a running migration. The viewer image ships no migrations and cannot run them, yet it built the schema on every SQLite start — and since compose starts both containers together, it could add tables while the backup container was migrating or inspecting the file, crash-looping what is usually the only copy of someone's Telegram history. The viewer now builds a schema only into a database with no tables at all. Fresh installs are unaffected. (#294)
- The two schema paths are now provably identical, and both databases are tested for real. SQLite was built by the models and PostgreSQL only by migrations, so the two drifted with nothing able to notice: 54 differences on SQLite and 50 on PostgreSQL when first measured. One was a live bug —
media.file_pathwas 500 characters on a real PostgreSQL install but unbounded in the models, so a long media path hard-failed there and worked on SQLite. Migration021aligns them: nullability on 35 columns, the declared defaults, that widening, a missing foreign key on reactions, eight SQLite integer widths, and an empty table left behind by an older migration. Every step reads the live schema first and does nothing where the shape already matches. (#293) - Revoking access now closes the channels that were already open. Logging out, deactivating or deleting a viewer, revoking a share token and session expiry used to stop future logins while the principal's open WebSocket kept receiving events and its push subscriptions kept firing notifications that carry sender names and message text — nothing ever deleted the subscription rows, and delivery trusted the grant snapshot taken at subscribe time. Every revoking path now closes the matching sockets, purges the principal's push subscriptions, and push delivery double-checks that a subscription's owner still exists before sending; logging out also unsubscribes the browser itself. A fully idle expired socket can persist up to one cleanup sweep — the deliberate price of keeping session checks off the message-delivery path. (#306)
- Migration
021hardened before it ever shipped. Databases provisioned by the models never gained the audit-log indexes, so the admin audit page scanned in full forever; the revoked-token backfill resurrected share tokens 7.x treated as dead, and now fails closed instead; and a crash immediately after a rebuild step left a temporary table behind that crash-looped every restart, which is now cleared defensively before each rebuild. (#300) - A percent-encoded database URL, or a raw
%in a PostgreSQL password, no longer crash-loops the backup container. Dead configuration calls that choked on it are gone, and credentials are decoded before use. A separate crash loop is fixed alongside it: the container's SQLite path chain omittedDATABASE_DIR, so such an install was inspected at one path and migrated at another, and the initial migration then ran again against the real database. (#285, #293) - An archived attachment can no longer end up permanently unopenable. Concurrent media ingest briefly published an intermediate name into the shared store, so a chat's symlink could be left pointing at a name that no longer existed; publishing is now atomic. The shared-media sharding migration moved relative symlinks a directory deeper without rewriting their targets, breaking every one it touched — targets are now rewritten as part of the move, and the migration is safe to re-run. A single unreadable file no longer aborts that migration, and with it the container's startup. (#286)
- A dead session fails immediately instead of burning the whole retry ladder, a failed or cancelled live download no longer leaves an orphaned
.partfile behind, and the listener detaches its handlers when it stops, so a restart leaves one listener attached to the shared client rather than one more each time. (#286) - More identifiers kept out of the logs. An unhandled viewer error no longer writes the request path — which carries the chat id and the sender's filename — into the log, and the redaction sits inside the framework's own error handling, so the exception never reaches its unconditional traceback either. On the capture side, Telegram errors whose text spells out a chat or user id now log the error type only, closing at the sites that were missed the same leak class 7.33.2–7.33.4 addressed. Chat ids, media paths and message payloads are no longer written to the browser console. (#286, #289, #290, #297)
Changed
- The gates that publish images now test what they ship. Both the merge gate and the publish gates run against a real PostgreSQL server, and the job fails if the PostgreSQL half of the suite skips — a silently skipped backend is how the schema drift above survived. Dependencies install from the lockfile, so the tests exercise the exact set the images ship rather than a fresh resolution. Third-party actions that can see the Docker Hub token are pinned to full commit hashes, code scanning now covers the viewer's JavaScript as well as the Python, and the publish path filters include the scripts and migrations that are baked into the image. (#285, #293, #299)
- Third-party assets in the viewer are pinned by content hash, closing the CDN-compromise route into an authenticated viewer origin. (#289)
- Documentation caught up with the code: the shipped compose file pinned an image 26 minor releases behind, the environment-variable table omitted variables that exist, and the example database URL pointed outside the mounted volume — where following it lost the archive when the container was recreated. (#284)
📋 Full changelog: docs/CHANGELOG.md