# UPDATE_ROLLBACK.md — In-Bot Update / Downgrade System

This document describes the Update Pack F09 in-bot ZIP update system
and its downgrade/rollback path, and is required reading before ever
running "🔄 به‌روزرسانی" from the admin panel on a live production bot.

## Design goals (and honest limits)

Shared hosting cannot offer a true atomic filesystem swap (no
symlink-swap deploy pattern is guaranteed available, `shell_exec` is
often disabled, and PHP itself is the process serving the very files
being replaced). This system is built around **best-effort safety with
an honest, always-available rollback path** rather than pretending to
guarantee atomicity it cannot deliver. Concretely:

- **Backup always happens first, automatically, before anything else
  is touched.** An update never proceeds without a fresh backup having
  been created and (if the files channel is configured) uploaded.
- **The live `config/app.php` and everything under `storage/` are
  NEVER touched by an update/downgrade swap**, regardless of what the
  uploaded zip contains — verified by
  `tests/phase_update_security_test.php`'s explicit "even if the
  update zip contains its own config/app.php, swap() never overwrites
  the live one" check.
- **A failed step never deletes anything already working.** `swap()`
  only ever writes NEW file contents over old ones — a partially
  completed swap leaves the untouched files exactly as they were
  before, never half-deleted.
- **"⬅️ بازگشت/Downgrade" is shown on every screen of the update flow,
  including every failure state** — a stuck admin always has an
  immediate way back.

## v1.6.1 authoritative flow

The package may be root-level or wrapped in one folder and may include `tfb-update.json`. Validation rejects ambiguous roots, traversal, absolute paths and symlinks. `config/app.php`, local config, storage, install locks, env/private keys are never overwritten. After explicit confirmation the bot creates a safety code+DB backup, acquires a 15-minute file lock, snapshots maintenance settings, then performs **validate → extract → preflight → apply files → migrate current schema → verify pending=0/classes/boot**. Every path restores maintenance and releases the lock in `finally`, and ends by editing the admin workspace with a Persian success/failure terminal message.

Downgrade uses the same pipeline after its own safety backup. Schema is forward-only; no destructive down migration is invented. If older code is incompatible with the current schema, verification fails terminally and the matching DB backup can be restored separately.

## Historical flow notes

```
Admin taps "🚀 شروع به‌روزرسانی"
   │
   ├─ 1. Automatic backup (BLOCKING — update never proceeds without this)
   │     ├─ code: zip the live tree (excluding storage/, .git/, vendor/,
   │     │  node_modules/, dist/, tests/) to a temp file, upload to the
   │     │  files channel via TelegramClient::sendLocalDocument(), record
   │     │  metadata in `release_backups`, then delete the local temp zip
   │     │  immediately (never kept permanently on host disk)
   │     └─ database: BackupService::createBackup() (pure-PDO logical
   │        dump, same mechanism as the Phase 07 "🗜 پشتیبان‌گیری" button),
   │        also uploaded to the files channel + recorded
   │
   ├─ 2. Admin uploads the update .zip (prompted after backup confirms)
   │
   ├─ 3. validateZip()
   │     ├─ size cap (60 MB)
   │     ├─ must be a structurally valid zip (ZipArchive::CHECKCONS)
   │     ├─ path-traversal guard: ANY entry containing ".." or an
   │     │  absolute path anywhere in the archive rejects the WHOLE zip
   │     └─ must contain top-level src/, public/, bootstrap/, config/
   │
   ├─ 4. extractToStaging() — extracts to storage/update_staging/
   │     (never a web-served path; storage/.htaccess already blocks it)
   │
   ├─ 5. preflight() — PHP version check + re-verify the staged copy's
   │     required directories are actually present after extraction
   │
   ├─ 6. runMigrations() — the SAME Tfb\Install\Migrator::upgrade() the
   │     installer itself uses; idempotent, additive-only, never drops
   │     data. If migration throws, the swap step below is SKIPPED
   │     entirely — the live code is never replaced with a version
   │     whose schema expectations the current DB doesn't yet satisfy.
   │
   ├─ 7. swap() — copies every staged file over the live tree EXCEPT
   │     config/app.php and everything under storage/
   │
   ├─ 8. ensureInstallerDisabledAfterUpdate() — defensive: if the
   │     update zip happened to contain its own public/install.php,
   │     it is deleted/stubbed again immediately (an update must never
   │     resurrect a reusable installer)
   │
   └─ 9. postUpdateHealthCheck() — runs a tiny subprocess that re-boots
         the app via the SAME bootstrap/app.php the real front
         controller uses and checks App::isInstalled() still returns
         true. On shared hosts where shell_exec() is disabled, this
         check is skipped with a clear note (not treated as failure) —
         the admin is told to manually test /start and /admin instead.
```

Every step's outcome is reported back to the admin in Persian, and the
"⬅️ بازگشت/Downgrade" button appears immediately if `postUpdateHealthCheck()`
reports failure, or if any earlier step failed.

## Downgrade

A downgrade is structurally **just an update using an older backup
zip** — it runs through the exact same `validateZip → extractToStaging
→ preflight → runMigrations → swap → ensureInstallerDisabledAfterUpdate
→ postUpdateHealthCheck` pipeline as a forward update, using a
previously-created `release_backups` code-zip (downloaded back from
the files channel by its `file_id`) as the "update package".

```
Admin: 🔄 به‌روزرسانی → ⬅️ بازگشت/Downgrade
   │
   ├─ لیست آخرین ۵ بکاپ کد (از جدول release_backups، فایل واقعی در
   │  کانال فایل‌ها) نمایش داده می‌شود
   │
   ├─ انتخاب یک بکاپ → تأیید صریح («ادامه؟»)
   │
   └─ اجرای همان pipeline بالا با آن بکاپ به‌عنوان بسته "آپدیت"
```

**Important limitation, stated honestly**: a downgrade restores the
CODE from the chosen backup point, but does **not** automatically
restore the DATABASE to that point in time (schema migrations are
additive/idempotent by design and safe to re-run, but any DATA changes
made between the backup and the downgrade — new tests built, new
users, etc. — are not reverted by a code-only downgrade). If a
downgrade is needed because of a genuinely broken update, use the
Phase 07 "🗜 پشتیبان‌گیری → بازیابی" DATABASE restore feature
**separately** if you also need to roll back data, with the same
two-step confirmation-phrase safety it already has.

## Security

- Only a **super admin** (first id in `admins.ids`, or a user row with
  `is_super_admin=1`) may reach any update/downgrade action —
  enforced at the top of every `AdminUpdateController` method.
- Every update/downgrade action is written to `admin_audit_logs`.
- The update zip upload path traversal guard is unit-tested
  (`tests/phase_update_security_test.php`) with a real malicious zip
  containing a `../../../etc/evil.php` entry — confirmed rejected.
- `config/app.php` (bot token, DB credentials, webhook secret) and all
  of `storage/` (logs, cache, backups, uploaded session state) are
  categorically excluded from any swap operation — this is enforced in
  code (`UpdateService::EXCLUDED_FROM_SWAP`), not just documented.

## What happens if the webhook needs to change (rare)

A code update normally does **not** require re-registering the
webhook (the URL and secret token in `config/app.php` are untouched).
The one scenario where a fresh `setWebhook` call would matter is a bot
TOKEN change — that is handled by the separate one-time secure link
flow in `docs/SECURITY.md` §"F10 — Secure Core Settings Console", which
already re-registers the webhook as part of applying a token change.
