Compare commits

...

22 Commits

Author SHA1 Message Date
92b157eda4 CLAUDE.md: owner's standing server authorization; recovery keys copy is with the owner
What an agent may do on the servers without asking (read the password
file, SSH, deploy and restart on 216, LAN transfers), what still needs a
question (deleting data, changes on 249/247, /etc and .env, keys, firewall,
credit spend), and what is never allowed. SSH example fixed for zsh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:48:48 +03:30
4efe6e3f6b Cut-over to 216: docs, CLAUDE.md and nginx source describe 216 as the live server
Arvan now sends both domains to 216 (2026-10-18). The final 249 dump was
restored on 216 with equal row counts, 249's app is stopped and disabled,
its replication slot dropped and 247's 249-era units disabled. The 249-era
archive on 247 was age-encrypted to 216's key and the plaintext shredded.
qdrant.tadrisino2.ir broke at the same moment (Django 400 via Arvan) and is
recorded as waiting on the senior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:40:47 +03:30
43e02adf37 216 sshd: LoginGraceTime back to 2m (30s silently cut off people typing a long password)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:59:59 +03:30
e25ffb5575 CLAUDE.md: 216 password is the AdminUser-Password line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:48:23 +03:30
eb03b467d4 216: shared admin account mathapp-admin, temp removed, hostname mathapp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:50:17 +03:30
f2c57c1ad1 docs: step-by-step cut-over to 216 with rollback, what waits on others, after-cut-over list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:18:40 +03:30
f6d779255d docs: what was tested on 216 (end-to-end user flow, restore from the 247 encrypted copy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:08:38 +03:30
4cd859b5fa 216 backups encrypted on 216: pgBackRest (AES-256) + age-encrypted payment CSVs, pushed write-only to 247 with rrsync; health watches pg_stat_archiver and OFFSITE_SYNC_STATUS_FILE; 216 scripts/configs in ops/server216; plaintext pg216 pipeline removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:43:00 +03:30
dd98f16d12 Live backup of 216 on 247 (zareian-pg216-* units, Postgres 16 tools in our dir, SCRAM hash only on 216), backup scripts take PGB_ROOT/PGB_BIN/PGB_PORT with 249 defaults, anti-bot layers documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:22:56 +03:30
bd329f83ae Dedicated server 78.38.35.216: state and cut-over order in docs, CLAUDE.md entry, password snippet for two Password lines, archive-app-log.sh finds its project dir
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:06:04 +03:30
670513b462 tools/figures: server_fixups.json (corrections of already-published questions and lessons), doc line
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:36:00 +03:30
133233144e Seed: grade 12 questions (every lesson 4+ per lesson, answers computed in code), minus-sign and doubled-x fixes in grade 10 and 11
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:35:34 +03:30
a5a93ef6ba Seed: grade 11 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:27:10 +03:30
5f963e2898 Seed: grade 10 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:16:51 +03:30
7bd7c637b4 Seed: grade 9 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 03:00:27 +03:30
ac96f93abc Seed: grade 8 questions (every lesson 4+ per lesson, answers computed in code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:48:33 +03:30
9903e0ec91 Seed: grade 5 rebuilt (every lesson 4+ questions computed in code), wording fixes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:30:33 +03:30
759bb0d575 Seed: grade 4 rebuilt (every lesson 4+ questions computed in code), slash decimals fixed in chapter 5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:06:21 +03:30
f8a93a37c9 Seed: grade 7 rebuilt (every lesson 5+ questions computed in code), lost-exponent questions fixed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:47:44 +03:30
9eae99340d Seed: grade 6 rebuilt (every lesson 5+ questions, answers computed in code), supplementary/complementary text fixed in grades 4/6/7, '/' decimals to '٫', wrong keys fixed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:31:28 +03:30
f57deb9c1d Seed: grade 3 rebuilt (every lesson 5+ questions, wrong figures/lesson text fixed), Persian digits and plain fractions across all grades, apply_fixups gains lesson/question_set, ledger save chunked for SQLite
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:27:22 +03:30
5fbbd371fe Seed: questions for the 499 lessons that had none; every lesson now has questions (5,406/5,406)
998 four-choice questions (2 per lesson), written from each lesson's own text,
worked example and the book's extracted problem. Mostly grades 3, 6, 7 (books
imported before us), plus 4, 5 and a few in 8-12. Hands-on lessons (drawing,
measuring, folding) got text questions that test the same idea.
Checked: structure (4 distinct choices, no LaTeX), a blind re-solve of all 998
against the key (no mismatch), and content.tests_seed_content seeding the full
file. Not checked: an independent second solver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:15:13 +03:30
40 changed files with 359900 additions and 14067 deletions

View File

@@ -39,6 +39,9 @@ DB_PORT=5432
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده می‌گیرد (ops/pgbackup/). روی سرور: zareian_247. # اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده می‌گیرد (ops/pgbackup/). روی سرور: zareian_247.
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار می‌دهد. روی لپ‌تاپ خالی. # خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار می‌دهد. روی لپ‌تاپ خالی.
OFFSITE_WAL_SLOT= OFFSITE_WAL_SLOT=
# سرورِ اختصاصی (۲۱۶): فایلی که tadrisino-offsite-sync زمانِ آخرین ارسالِ بکاپِ رمزشده به ۲۴۷ را در آن می‌نویسد.
# روی ۲۱۶: /var/lib/tadrisino-backup/offsite-synced-at (بر OFFSITE_WAL_SLOT مقدم است). روی لپ‌تاپ خالی.
OFFSITE_SYNC_STATUS_FILE=
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند # ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند

154
CLAUDE.md
View File

@@ -27,7 +27,7 @@ answers from the same book. Two repos, one API contract: this Django + DRF backe
| Reading a textbook with a vision model (costs, model choice) | `docs/extraction-playbook.md`, `data/textbooks/README.md` | | Reading a textbook with a vision model (costs, model choice) | `docs/extraction-playbook.md`, `data/textbooks/README.md` |
| Tutor cost per question, subscription margins, guardrails | `docs/ai-cost-and-guardrails.md` | | Tutor cost per question, subscription margins, guardrails | `docs/ai-cost-and-guardrails.md` |
| Measured model prices (append-only ledger: add a dated row, never overwrite) | `docs/model-prices.md` | | Measured model prices (append-only ledger: add a dated row, never overwrite) | `docs/model-prices.md` |
| Load testing (laptop and the shared server) | `tools/loadtest/README.md` | | Load testing (laptop and the server) | `tools/loadtest/README.md` |
| Frontend structure and its deploy | `~/Desktop/MathApp_frontend/README.md` | | Frontend structure and its deploy | `~/Desktop/MathApp_frontend/README.md` |
| Android build, signing, what the next APK must carry | `~/Desktop/MathApp_frontend/android/README.md` | | Android build, signing, what the next APK must carry | `~/Desktop/MathApp_frontend/android/README.md` |
@@ -37,35 +37,65 @@ Before writing a new doc, check this table: the same fact in three places drifts
## ⛔ Working on the servers — read before every SSH session ## ⛔ Working on the servers — read before every SSH session
The owner often says "put it on the server" / "deploy it" and expects you to do it yourself. You The owner often says "put it on the server" / "deploy it" and expects you to do it yourself, and
may, but these machines are **shared company servers**, not ours. Our app is one tenant among many. "the server" means **216**. You may. 216 is ours alone, but it is production, and two other machines
A careless command can take down the whole company's git, mail and other teams' production apps, we still hold files on (249, 247) are **shared company servers** where our account has full root and
and there is no technical guard rail: our account has full root. **The only guard rail is you.** a careless command can take down the whole company's git, mail and other teams' apps. **The only
guard rail is you.**
### Standing authorization (the owner's, permanent: do not ask again)
The owner does not want to repeat this each session. When a task needs a server, start working:
- **Granted without asking:** read the passwords from `~/Desktop/رمز سرور.txt` («The password» below),
SSH to 216/249/247, look at anything of ours (status, logs, disk, DB counts, nginx config); on **216**,
deploy backend and frontend, restart `zareian-mathapp`, reload nginx, run management commands, take
backups, write into our own directories, and install a package from the mirror when a task needs it;
move files between servers over the LAN through 216.
- **Ask first (one Persian line saying what and why, then wait):** anything that deletes or overwrites
data (dropping/restoring a DB, removing backups, `rm` of more than our own temp files), any change on
249 or 247 beyond reading, `.env` and `/etc` edits on 216 (after the backup of rule 5), SSH keys,
`sshd_config`, firewall, users, reboots, and anything that spends GapGPT/SMS credit beyond a single check.
- **Never, even if asked in passing:** touch anything listed as not ours on 249/247, power a machine off,
print a password or secret.
- Every command still follows «Announce before you act» and the rules below. The auto-mode classifier may
block a shared-server action even with this authorization; then stop, say what was blocked, and give the
owner the exact command or ask them to approve it.
### What is ours — and nothing else is ### What is ours — and nothing else is
**`78.38.35.249` (`haghzad`, app server)**, account `administrator`: **`78.38.35.216` (hostname `mathapp`, the live server)**: a dedicated VM, the whole machine is ours. Log in
as `zareian` (sudo); the senior and the company owner share `mathapp-admin` (sudo); no other logins. The app
runs as the non-sudo user `mathapp`:
| Ours | Note | | Ours | Note |
|---|---| |---|---|
| `/home/administrator/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `logs/`, `.deploy-backups/` are untracked and live here | | `/home/mathapp/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `.pg_password`, `.redis_password`, `logs/`, `.deploy-backups/` are untracked and live here |
| `/home/administrator/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` | | `/home/mathapp/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` |
| `gunicorn-mathapp.service` = alias **`zareian-mathapp`** | always type the alias | | `gunicorn-mathapp.service` = alias **`zareian-mathapp`** | always type the alias |
| `zareian-redis.service` (port 6390) | our own Redis | | `zareian-redis.service` (6390), `postgresql@16-zareian` (5440, DB `zareian_mathapp`) | |
| Postgres cluster `14/zareian` (port 5440, DB `zareian_mathapp`) | our own cluster | | `tadrisino-*` timers (pgBackRest, offsite sync, payment CSV, blocklist), `zareian-applog-archive.timer` | sources in `ops/server216/` |
| `zareian-applog-archive.timer` | nightly log archive | | nginx `sites-enabled/mathapp`, `mathapp-front`, `snippets/zareian-*.conf` | sources in `ops/nginx/` |
| nginx: `sites-enabled/mathapp`, `sites-available/mathapp-front`, `snippets/zareian-*.conf` | source of truth is `ops/nginx/` | | `/home/zareian/stage/` | DB dumps from the cut-over (2026-10-18) |
**Not ours, never touch:** `~/mathapp` (old, not git, not what runs), the generic `gunicorn.service` **`78.38.35.249` (`haghzad`, shared, retired for us since 2026-10-18)**, account `administrator`: our app
(another team's app), `gitea`, `postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (port 5432), there is stopped and disabled, the replication slot is gone, Arvan no longer points at it. Still ours
`redis-server` (6379), `rabbitmq`, `metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`, and left in place as the rollback until about 2026-10-25: `/home/administrator/mathapp-v2`,
other people's home directories (`aria`, `artina`, `behdad`, `zahra`, `git`, `gitea`) and every `/home/administrator/mathapp_frontend_new`, `gunicorn-mathapp.service` (disabled; the `zareian-mathapp`
other nginx site. `metabase` and Artina's `celerybeat` crash-loop and eat ~3 of 8 cores; that is a alias returns with `enable`), `zareian-redis`, Postgres cluster `14/zareian` (5440, plaintext data as of
request to the server admin, not something to fix. the cut-over), `zareian-applog-archive.timer`, our nginx sites and snippets. Removing them is the owner's
call. **Not ours, never touch:** `~/mathapp`, the generic `gunicorn.service`, `gitea`,
`postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (5432), `redis-server` (6379), `rabbitmq`,
`metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`, other people's home directories and every
other nginx site.
**`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/` **`78.38.35.247` (backup server, shared)**, same account and password as 249: ours is only
and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its `~/zareian-pgbackrest-216/` (216's live backups, encrypted on 216), `~/zareian-pgbackup/` (the 249-era
own `postgresql@14-main`, which we never use. archive, every file now `age`-encrypted to 216's key; its `zareian-pg-*` / `zareian-applog-pull` units are
disabled), our lines in `~/.ssh/authorized_keys` and `~/.pgpass`, and `~/.ssh/zareian_*`. 247 never
holds a decryption key. It also runs other teams' services and its own `postgresql@14-main`.
On 216 every rule below applies except 4 and 7 (nothing there is shared); on 249 and 247 all of them.
### Rules (not negotiable) ### Rules (not negotiable)
@@ -73,18 +103,18 @@ own `postgresql@14-main`, which we never use.
`df -h /`. Know what is there before you act on it. `df -h /`. Know what is there before you act on it.
2. **No destructive command outside our directories.** No `rm -rf`, `chown -R`, `chmod -R`, 2. **No destructive command outside our directories.** No `rm -rf`, `chown -R`, `chmod -R`,
`find … -delete`, `DROP`, `docker system prune`, package upgrades, reboots, firewall changes. `find … -delete`, `DROP`, `docker system prune`, package upgrades, reboots, firewall changes.
Inside our directories, write paths out in full (`/home/administrator/mathapp-v2/...`), never Inside our directories, write paths out in full (`/home/mathapp/mathapp-v2/...`), never
rely on the current directory. rely on the current directory.
3. **Never `git clean`, never delete and re-clone the project.** `.env`, `.django_secret_key`, logs 3. **Never `git clean`, never delete and re-clone the project.** `.env`, `.django_secret_key`, logs
and backups are untracked; `git reset --hard origin/main` is the way back to a known tree. and backups are untracked; `git reset --hard origin/main` is the way back to a known tree.
4. **Restart only `zareian-mathapp`.** `systemctl restart gunicorn` is another team's app. nginx is 4. **Restart only `zareian-mathapp`.** `systemctl restart gunicorn` is another team's app. nginx is
shared: `sudo nginx -t` then `reload`, never `restart`. shared: `sudo nginx -t` then `reload`, never `restart`.
5. **Back up before editing anything under `/etc` or `.env`**, into 5. **Back up before editing anything under `/etc` or `.env`**, into
`~/mathapp-v2/.deploy-backups/<what>-before-<change>-<YYYYMMDD-HHMMSS>`, and say where it is. `/home/mathapp/mathapp-v2/.deploy-backups/<what>-before-<change>-<YYYYMMDD-HHMMSS>`, and say where it is.
Database: `deploy.sh` already takes a `pg_dump` before `migrate`; for any manual data change take Database: `deploy.sh` already takes a `pg_dump` before `migrate`; for any manual data change take
one yourself first. one yourself first.
6. **Disk is the standing risk.** Check `df -h /` before anything that writes a lot. A full disk 6. **Disk is the standing risk.** Check `df -h /` before anything that writes a lot. On 216 a full disk
takes Gitea and mail down with us. stops our DB; on 249/247 it takes Gitea and mail down with us.
7. **Do not use other teams' resources**, even "just an empty database" on their Redis or Postgres, 7. **Do not use other teams' resources**, even "just an empty database" on their Redis or Postgres,
their queues or their nginx sites. Ask the owner; it becomes a request to the server admin. their queues or their nginx sites. Ask the owner; it becomes a request to the server admin.
8. **Secrets stay put.** Read `.env` only through Django (`venv/bin/python manage.py shell -c` printing 8. **Secrets stay put.** Read `.env` only through Django (`venv/bin/python manage.py shell -c` printing
@@ -107,19 +137,25 @@ destroy the password copy, **never** power anything off.
### The password ### The password
Access is a password for the shared `administrator` account (same for 249 and 247). The owner The owner keeps the passwords in `~/Desktop/رمز سرور.txt`; read them from there, never ask for them in
keeps it in `~/Desktop/رمز سرور.txt` and wants agents to read it from there rather than ask in chat. 216 (`zareian`) uses the `AdminUser-Password` line; 249 and 247 (`administrator`) use the
chat; the auto-mode classifier may still block that read until the owner confirms in the first `Password` line. Take only the value, everything after the first `: ` (stripping only the label
session. Take only the password line, everything after the first `: ` (stripping only the label
once sent the whole line and SSH refused it): once sent the whole line and SSH refused it):
```bash ```bash
sed -n '/^Password/{s/^[^:]*: *//;p;}' ~/Desktop/'رمز سرور.txt' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" awk '/^AdminUser-Password/{sub(/^[^:]*: */,"");printf "%s",$0;exit}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pwn" && chmod 600 "$SCRATCH/.pwn" # 216
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>' sed -n '/^Password/{s/^[^:]*: *//;p;q;}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" # 249/247
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw" # one connection, reused (the shell is zsh: write the -o options out, a "$C" variable does not split)
rm -P "$SCRATCH/.pw" # as soon as the work is done (macOS has no shred); recreate it next time sshpass -f "$SCRATCH/.pwn" ssh -o ControlMaster=auto -o ControlPath=/tmp/z216-%C -o ControlPersist=30m zareian@78.38.35.216 '<command>'
sshpass -f "$SCRATCH/.pwn" ssh -o ControlPath=/tmp/z216-%C zareian@78.38.35.216 'sudo -S -p "" <command>' < "$SCRATCH/.pwn"
ssh -o ControlPath=/tmp/z216-%C -O exit zareian@78.38.35.216 # when done, in its own call
rm -P "$SCRATCH/.pwn" # as soon as the work is done (macOS has no shred); recreate it next time
``` ```
The laptop's connection is slow: move anything bigger than a few MB **between servers over the LAN**
(216 = `192.168.200.163`, 249 = `.164`, 247 = `.167`), never piped through the laptop. 216 has `sshpass`;
a password copied to 216 for that goes in `/home/zareian/` with mode 600 and is deleted in the same command.
Never put it on a command line (`ps` shows argv to every user on the server), in the repo, a Never put it on a command line (`ps` shows argv to every user on the server), in the repo, a
memory, a commit or a note; never echo it. Many SSH connections in a row can trip a lockout: open memory, a commit or a note; never echo it. Many SSH connections in a row can trip a lockout: open
one `ControlMaster` connection (socket under `/tmp`, macOS limits the path to 104 bytes). Do not one `ControlMaster` connection (socket under `/tmp`, macOS limits the path to 104 bytes). Do not
@@ -130,17 +166,18 @@ create SSH keys, edit `sshd_config` or change the password unless the owner asks
Backend (commit and push to Gitea first; the server deploys what is on `origin/main`): Backend (commit and push to Gitea first; the server deploys what is on `origin/main`):
```bash ```bash
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'cd /home/administrator/mathapp-v2 && ./deploy.sh' sshpass -f "$SCRATCH/.pwn" ssh zareian@78.38.35.216 'sudo -S -p "" -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"' < "$SCRATCH/.pwn"
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pw" sshpass -f "$SCRATCH/.pwn" ssh zareian@78.38.35.216 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pwn"
``` ```
`deploy.sh` saves any hand edits as a patch, resets to `origin/main`, installs, backs up the DB, `deploy.sh` runs as `mathapp`: it saves any hand edits as a patch, resets to `origin/main`, installs,
migrates, runs `seed_content`, collectstatic, and checks health through the gunicorn socket. The backs up the DB, migrates, runs `seed_content`, collectstatic, and checks health through the gunicorn
restart is separate because `sudo` inside the script cannot prompt. Then **verify; never assume**: socket. Its own restart fails (`mathapp` has no sudo) and says so; the restart is the second line. Then
**verify; never assume**:
- `systemctl show zareian-mathapp -p ActiveEnterTimestamp` is after your deploy (a file on disk is not - `systemctl show zareian-mathapp -p ActiveEnterTimestamp` is after your deploy (a file on disk is not
a deployed file: once the server served two-day-old code while every file was current); a deployed file: once the server served two-day-old code while every file was current);
- `git -C /home/administrator/mathapp-v2 log -1 --format=%h` equals your local `main`; - `sudo -u mathapp git -C /home/mathapp/mathapp-v2 log -1 --format=%h` equals your local `main`;
- `curl https://mathapi.tadrisino2.ir/api/health` → `{"status":"ok"}` (details need `X-Internal-Key`); - `curl https://mathapi.tadrisino2.ir/api/health` → `{"status":"ok"}` (details need `X-Internal-Key`);
- the thing you changed, exercised once from outside. - the thing you changed, exercised once from outside.
@@ -148,7 +185,7 @@ If the server cannot reach Gitea (`git fetch` 504 from the server, 200 from the
`DEPLOY_BUNDLE` in `docs/operations.md` «دیپلوی». `DEPLOY_BUNDLE` in `docs/operations.md` «دیپلوی».
Frontend: push to `upstream` (`Tadrisino/MathApp_frontend`) `main` (the owner authorized pushing and Frontend: push to `upstream` (`Tadrisino/MathApp_frontend`) `main` (the owner authorized pushing and
deploying it without asking), then `cd /home/administrator/mathapp_frontend_new && ./deploy.sh` (builds deploying it without asking), then as `mathapp` `cd /home/mathapp/mathapp_frontend_new && ./deploy.sh` (builds
into `dist.next`, swaps, keeps `dist.prev`). `config.js` and `index.html` are cached by ArvanCloud; into `dist.next`, swaps, keeps `dist.prev`). `config.js` and `index.html` are cached by ArvanCloud;
purge them there when they change. purge them there when they change.
@@ -264,9 +301,13 @@ Invariants that are not obvious from one file:
## Current state, by area ## Current state, by area
### Infrastructure (server 249) ### Infrastructure (server 216)
- Postgres cluster `14/zareian`, port 5440, localhost only (`DB_*` in `.env`; without `DB_NAME` the app - Dedicated VMware VM, Ubuntu 24.04, 8 cores (Xeon E5-2690 v3), 12 GB RAM, 97 GB disk; outbound IP
`78.38.35.228`. ufw allows 22/80/443; FireHOL blocklist, `ufw limit 22` and stepped fail2ban against SSH
brute force. apt/pip/npm come from Iranian mirrors (`docs/operations.md` «سرور»).
- Traffic: ArvanCloud → 216:443 for both `math.` and `mathapi.tadrisino2.ir` (origin switched 2026-10-18).
- Postgres 16 cluster `16/zareian`, port 5440, localhost only (`DB_*` in `.env`; without `DB_NAME` the app
uses SQLite, which is what local dev and tests use). `max_connections=60`. uses SQLite, which is what local dev and tests use). `max_connections=60`.
- Connections come from a per-worker pool (`DB_POOL_MAX_SIZE` 8); a full pool answers 503 + `Retry-After` - Connections come from a per-worker pool (`DB_POOL_MAX_SIZE` 8); a full pool answers 503 + `Retry-After`
(`config/db.py`), and `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`. (`config/db.py`), and `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`.
@@ -280,27 +321,28 @@ Invariants that are not obvious from one file:
- nginx (versions in `ops/nginx/`): only ArvanCloud and the machine itself get through (direct IP → 403); - nginx (versions in `ops/nginx/`): only ArvanCloud and the machine itself get through (direct IP → 403);
CSP and security headers on the frontend; API body limit 20 MB; HSTS. Verified in Chrome; **not** verified CSP and security headers on the frontend; API body limit 20 MB; HSTS. Verified in Chrome; **not** verified
in the Android WebView or through `pay.html` to the gateway. in the Android WebView or through `pay.html` to the gateway.
- Capacity (load test on the server, `tools/loadtest/play.py`): 300 players ≈ 88 req/s, median 0.1 s; - Capacity: measured only on 249 (shared CPU): 300 players ≈ 88 req/s, median 0.1 s; 600 ≈ 137 req/s,
600 ≈ 137 req/s, median 1.4 s, no errors; 1,000 hits the ceiling (~135 req/s, p95 15 s). The bottleneck median 1.4 s; 1,000 hit the ceiling. **216 has not been load-tested yet** (`tools/loadtest/README.md`); with
is the shared CPU, not the DB pool. More capacity needs the neighbours' crash-loops fixed plus more cores more cores, 8 workers and `DB_POOL_MAX_SIZE` 6.
(then 8 workers with `DB_POOL_MAX_SIZE` 6).
- `/api/health`: public answer is only ok/error; full details (DB, migrations, cache, pool, integrations, - `/api/health`: public answer is only ok/error; full details (DB, migrations, cache, pool, integrations,
`aiProvider`, `offsiteBackup`, warnings, `clientIp`) need `X-Internal-Key`. Swagger/ReDoc/schema need an `aiProvider`, `offsiteBackup`, warnings, `clientIp`) need `X-Internal-Key`. Swagger/ReDoc/schema need an
admin session. admin session.
### Backups and logs (server 247) ### Backups and logs (encrypted on 216, copied to 247)
Every Postgres change streams live to 247 (`pg_receivewal` over a restricted SSH tunnel, slot pgBackRest on 216 archives WAL continuously (at most a minute unarchived) with a weekly full and a daily
`zareian_247`, async; `max_slot_wal_keep_size=1GB` protects 249's disk) plus a daily base backup (7 kept). diff backup, encrypted (`aes-256-cbc`); payment CSVs every 6 hours, encrypted with `age`.
`deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). Payment events are exported to CSV on `tadrisino-offsite-sync` copies both to `~/zareian-pgbackrest-216/` on 247 every minute with a write-only
247 every 6 hours; the app log is archived nightly on 249 and pulled hourly to 247, where it is kept forever. key. 247 holds only ciphertext; the keys are in `/etc/tadrisino-backup/RECOVERY-KEYS` on 216, and the owner
Restore procedure, monitoring and what was tested: `docs/operations.md` «بکاپ و بازیابی». Restoring onto 249 keeps a copy outside the servers (since 2026-10-18); without them 247's copy is unreadable.
has been done before. `deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). The app log is archived nightly on
216 but **not yet shipped to 247**. `sudo -u postgres tadrisino-restore-check` restores into a temp dir and
compares counts (run after the cut-over: equal). Details: `docs/operations.md` «بکاپ و بازیابی».
### Content ### Content
- `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects, - `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects,
89 units, 1,145 islands, 5,406 lessons, 18,522 questions. `deploy.sh` runs `seed_content` on every release. 89 units, 1,145 islands, 5,406 lessons (every one has at least one question), 39,156 questions. `deploy.sh` runs `seed_content` on every release.
- `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers - `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers
what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`, what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`,
`steps` and `figures` are only filled when empty. Questions bind to lessons **by title path** `steps` and `figures` are only filled when empty. Questions bind to lessons **by title path**
@@ -369,6 +411,10 @@ has been done before.
- `POST /api/ai/ask` is RAG: Qdrant retrieval filtered by book and chapter → GapGPT generation - `POST /api/ai/ask` is RAG: Qdrant retrieval filtered by book and chapter → GapGPT generation
(`OPENAI_BASE_URL`, model `gpt-4.1-mini`, prompt `tutor-v12-age-tone`). `usedChunks` names the book chunks (`OPENAI_BASE_URL`, model `gpt-4.1-mini`, prompt `tutor-v12-age-tone`). `usedChunks` names the book chunks
used. Without `OPENAI_API_KEY`/`QDRANT_API_KEY` and `DEBUG=0` it answers 503, never a stub. used. Without `OPENAI_API_KEY`/`QDRANT_API_KEY` and `DEBUG=0` it answers 503, never a stub.
- **Broken since the cut-over (2026-10-18):** `qdrant.tadrisino2.ir` (behind Arvan, origin server not ours
and unknown to us) now answers a Django `400 Bad Request` page, not Qdrant, from 216 and from the laptop;
every retrieval fails and the student sees «سرویس در دسترس نیست». Its Arvan origin has to be put back by
the senior; nothing in our code or `.env` changed.
- Answers are plain text, step by step, math-only (`rag/services/guardrails.py`; self-harm messages get a - Answers are plain text, step by step, math-only (`rag/services/guardrails.py`; self-harm messages get a
kind reply with helpline numbers), tone by age, with an optional `<diagram>` JSON block that the frontend kind reply with helpline numbers), tone by age, with an optional `<diagram>` JSON block that the frontend
draws (`rag/services/diagram.py`, frontend `src/diagram/`). Solutions marked `disagrees` never reach the draws (`rag/services/diagram.py`, frontend `src/diagram/`). Solutions marked `disagrees` never reach the

View File

@@ -13,7 +13,7 @@
| **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` | | **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` |
| **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud | | **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud |
| **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) | | **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) |
| **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۱۸٬۵۲۲ سوال | | **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۳۹٬۱۵۶ سوال |
| **تست** | ۹۷۳ تستِ خودکار، همه سبز | | **تست** | ۹۷۳ تستِ خودکار، همه سبز |
| **سرویس‌های بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوه‌نگار (پیامک) · زرین‌پال و کافه‌بازار (پرداخت) | | **سرویس‌های بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوه‌نگار (پیامک) · زرین‌پال و کافه‌بازار (پرداخت) |
@@ -97,11 +97,11 @@ python manage.py spectacular --file schema.yaml --format openapi # خروجی
## استقرار ## استقرار
سرویس روی یک سرورِ مشترکِ شرکت اجرا می‌شود و مالِ این پروژه فقط پوشه‌ی `~/mathapp-v2` و سرویسِ سرویس روی سرورِ اختصاصیِ `78.38.35.216` اجرا می‌شود؛ اپ با کاربرِ `mathapp` در `/home/mathapp/mathapp-v2` و سرویسِ
`zareian-mathapp` است. کلِ انتشار: `zareian-mathapp`. کلِ انتشار (جزئیات در `docs/operations.md` «دیپلوی»):
```bash ```bash
ssh -t administrator@78.38.35.249 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp' ssh -t zareian@78.38.35.216 'sudo -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"; sudo systemctl restart zareian-mathapp'
``` ```
`deploy.sh` کد را با `origin/main` یکی می‌کند، وابستگی‌ها را نصب می‌کند، پیش از `migrate` از دیتابیس بکاپ می‌گیرد، `deploy.sh` کد را با `origin/main` یکی می‌کند، وابستگی‌ها را نصب می‌کند، پیش از `migrate` از دیتابیس بکاپ می‌گیرد،

View File

@@ -14,6 +14,7 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
""" """
import logging import logging
import os import os
import time
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
@@ -160,6 +161,8 @@ def _offsite_backup() -> dict:
""" """
if connection.vendor != 'postgresql': if connection.vendor != 'postgresql':
return {'state': 'not_postgres'} return {'state': 'not_postgres'}
if settings.OFFSITE_SYNC_STATUS_FILE:
return _pushed_backup()
slot = settings.OFFSITE_WAL_SLOT slot = settings.OFFSITE_WAL_SLOT
if not slot: if not slot:
return {'state': 'not_configured'} return {'state': 'not_configured'}
@@ -193,6 +196,45 @@ def _slot_state(row) -> dict:
return {'state': state, 'lagBytes': lag} return {'state': state, 'lagBytes': lag}
def _pushed_backup() -> dict:
"""
بکاپِ رمزشده‌ی ۲۱۶: pgBackRest هر WAL را با archive_command آرشیو می‌کند (pg_stat_archiver) و
`tadrisino-offsite-sync` مخزن را هر دقیقه به ۲۴۷ می‌فرستد و زمانش را در OFFSITE_SYNC_STATUS_FILE می‌نویسد.
کهنگیِ آخرین آرشیو هشدار نیست: دیتابیسِ بی‌تغییر WALِ تازه‌ای ندارد که آرشیو شود.
"""
try:
with connection.cursor() as cursor:
cursor.execute('SELECT last_archived_time, last_failed_time FROM pg_stat_archiver')
last_archived, last_failed = cursor.fetchone()
except Exception as exc: # noqa: BLE001
return {'state': 'error', 'error': type(exc).__name__}
try:
with open(settings.OFFSITE_SYNC_STATUS_FILE) as f:
synced_at = int(f.read().strip())
except FileNotFoundError:
synced_at = None
except (OSError, ValueError) as exc:
return {'state': 'error', 'error': type(exc).__name__}
return _pushed_state(last_archived, last_failed, synced_at, time.time())
def _pushed_state(last_archived, last_failed, synced_at, now) -> dict:
age = None if synced_at is None else int(now - synced_at)
if last_failed is not None and (last_archived is None or last_failed > last_archived):
state = 'archive_failing'
elif age is None:
state = 'never_synced'
elif age > settings.OFFSITE_SYNC_MAX_AGE_SECONDS:
state = 'sync_stale'
else:
state = 'ok'
return {
'state': state,
'syncAgeSeconds': age,
'lastArchivedAt': last_archived.isoformat() if last_archived else None,
}
# اتصال‌هایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و # اتصال‌هایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests # migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
# عوض می‌کند و تا بسته شدنِ قبلی هم‌زمان بالاست (یک pool). # عوض می‌کند و تا بسته شدنِ قبلی هم‌زمان بالاست (یک pool).
@@ -267,6 +309,9 @@ _OFFSITE_MESSAGES = {
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.', 'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
'lost': 'سرورِ بکاپ آن‌قدر قطع بود که زنجیره‌ی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.', 'lost': 'سرورِ بکاپ آن‌قدر قطع بود که زنجیره‌ی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
'error': 'وضعیتِ کپیِ زنده‌ی دیتابیس خوانده نشد.', 'error': 'وضعیتِ کپیِ زنده‌ی دیتابیس خوانده نشد.',
'archive_failing': 'آرشیوِ WAL (pgBackRest) شکست می‌خورد و تغییراتِ تازه بکاپ نمی‌شوند: sudo -u postgres pgbackrest --stanza=zareian check',
'never_synced': 'بکاپِ رمزشده هنوز هیچ‌وقت به سرورِ بیرونی (۲۴۷) نرسیده (فایلِ OFFSITE_SYNC_STATUS_FILE نیست).',
'sync_stale': 'ارسالِ بکاپِ رمزشده به ۲۴۷ عقب افتاده: journalctl -u tadrisino-offsite-sync',
} }

View File

@@ -1078,6 +1078,12 @@ LOG_DIR = os.environ.get('LOG_DIR', '')
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '') OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
# بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است. # بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است.
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20 OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
# سرورِ اختصاصی (۲۱۶): بکاپِ رمزشده‌ی pgBackRest؛ `tadrisino-offsite-sync` هر دقیقه آن را به ۲۴۷ می‌فرستد و زمانِ
# آخرین ارسالِ موفق (epoch) را در این فایل می‌نویسد (docs/operations.md «سرورِ اختصاصیِ جدید»). ست باشد، بر
# OFFSITE_WAL_SLOT مقدم است. OFFSITE_WAL_SLOT را فقط ۲۴۹ دارد و با سوئیچ به ۲۱۶ همراهِ _slot_state حذف می‌شود.
OFFSITE_SYNC_STATUS_FILE = os.environ.get('OFFSITE_SYNC_STATUS_FILE', '')
# ارسال هر دقیقه است؛ ۱۰ دقیقه بی‌ارسال یعنی چند اجرای پشتِ سرِ هم شکست خورده.
OFFSITE_SYNC_MAX_AGE_SECONDS = 600
# چرخشِ فایلِ لاگ: 'size' (پیش‌فرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمی‌ها پاک می‌شوند؛ # چرخشِ فایلِ لاگ: 'size' (پیش‌فرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمی‌ها پاک می‌شوند؛
# 'external' (سرور) = برنامه هرگز پاک نمی‌کند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابه‌جا و # 'external' (سرور) = برنامه هرگز پاک نمی‌کند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابه‌جا و

View File

@@ -3,6 +3,8 @@
پرسشِ واقعی از pg_replication_slots این‌جا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ روی سرور با اسلاتِ پرسشِ واقعی از pg_replication_slots این‌جا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ روی سرور با اسلاتِ
واقعی دیده شد (docs/operations.md «بکاپ»). واقعی دیده شد (docs/operations.md «بکاپ»).
""" """
from datetime import datetime, timedelta, timezone
from django.test import SimpleTestCase, override_settings from django.test import SimpleTestCase, override_settings
from config import health from config import health
@@ -27,13 +29,45 @@ class SlotStateTests(SimpleTestCase):
self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok') self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok')
@override_settings(OFFSITE_SYNC_MAX_AGE_SECONDS=600)
class PushedStateTests(SimpleTestCase):
"""بکاپِ رمزشده‌ی ۲۱۶ (_pushed_state). پرسشِ pg_stat_archiver و خواندنِ فایل روی سرور دیده شد، نه این‌جا."""
T0 = datetime(2026, 10, 17, 16, 0, tzinfo=timezone.utc)
def test_recent_sync_is_ok(self):
r = health._pushed_state(self.T0, None, 1000, now=1060)
self.assertEqual((r['state'], r['syncAgeSeconds']), ('ok', 60))
self.assertEqual(r['lastArchivedAt'], self.T0.isoformat())
def test_old_sync_is_stale(self):
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1601)['state'], 'sync_stale')
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1600)['state'], 'ok')
def test_no_status_file_is_never_synced(self):
self.assertEqual(health._pushed_state(self.T0, None, None, now=1000)['state'], 'never_synced')
def test_failure_after_last_success_wins(self):
later = self.T0 + timedelta(minutes=1)
self.assertEqual(health._pushed_state(self.T0, later, 1000, now=1001)['state'], 'archive_failing')
self.assertEqual(health._pushed_state(None, later, 1000, now=1001)['state'], 'archive_failing')
def test_old_failure_then_success_is_ok(self):
earlier = self.T0 - timedelta(minutes=1)
self.assertEqual(health._pushed_state(self.T0, earlier, 1000, now=1001)['state'], 'ok')
def test_idle_database_with_no_archive_yet_is_ok(self):
self.assertEqual(health._pushed_state(None, None, 1000, now=1001)['state'], 'ok')
class OffsiteWarningTests(SimpleTestCase): class OffsiteWarningTests(SimpleTestCase):
def test_ok_and_dev_are_silent(self): def test_ok_and_dev_are_silent(self):
self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), []) self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), [])
self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), []) self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), [])
def test_every_bad_state_warns(self): def test_every_bad_state_warns(self):
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error'): for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error',
'archive_failing', 'never_synced', 'sync_stale'):
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state) self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
def test_sqlite_reports_not_postgres(self): def test_sqlite_reports_not_postgres(self):

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,11 @@ class _Ledger:
self.write[key] = (kind, value) self.write[key] = (kind, value)
def save(self) -> None: def save(self) -> None:
existing = set(SeedRecord.objects.filter(key__in=list(self.write)).values_list('key', flat=True)) # دسته‌دسته: SQLite بیش از ۳۲٬۷۶۶ متغیر در یک پرس‌وجو نمی‌پذیرد و دفترِ یک seedِ تازه از آن بیشتر است.
keys = list(self.write)
existing = set()
for i in range(0, len(keys), 500):
existing.update(SeedRecord.objects.filter(key__in=keys[i:i + 500]).values_list('key', flat=True))
SeedRecord.objects.bulk_create([ SeedRecord.objects.bulk_create([
SeedRecord(key=k, kind=kind, value=value) SeedRecord(key=k, kind=kind, value=value)
for k, (kind, value) in self.write.items() if k not in existing for k, (kind, value) in self.write.items() if k not in existing

View File

@@ -0,0 +1,61 @@
"""tools/figures/apply_fixups.py --db: اصلاحِ محتوای منتشرشده روی دیتابیس، بدون از دست رفتنِ پاسخ‌های ثبت‌شده."""
import importlib.util
import os
from django.conf import settings
from django.test import TestCase
from content.models import Choice, Grade, Lesson, Node, Question, Unit, UserAnswer
_spec = importlib.util.spec_from_file_location(
'apply_fixups', os.path.join(settings.BASE_DIR, 'tools', 'figures', 'apply_fixups.py'))
apply_fixups = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(apply_fixups)
PATH = ['پایه سوم', 'فصل ۱', 'جزیره', 'درس']
class ApplyFixupsDbTests(TestCase):
def setUp(self):
g = Grade.objects.create(title='پایه سوم', icon='', color='')
u = Unit.objects.create(grade=g, title='فصل ۱', order=1)
n = Node.objects.create(unit=u, title='جزیره', order=1)
self.lesson = Lesson.objects.create(node=n, title='درس', intro_text='متن', example='مثالِ غلط',
steps=['گام'], figures=[{'where': 'example', 'diagram': {}}])
self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='(۱)/(۲) کدام است؟')
self.c = [Choice.objects.create(question=self.q, value=v, is_correct=(i == 0))
for i, v in enumerate(['(۱)/(۲)', '۲', '۳', '۴'])]
self.answer = UserAnswer.objects.create(user_id=7, question=self.q, choice=self.c[0], correct=True)
self.dup = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='تکراری')
def test_question_set_keeps_rows_and_answers(self):
problems = apply_fixups.apply_db({'question_set': [[PATH, '(۱)/(۲) کدام است؟', {
'prompt': '۱/۲ کدام است؟', 'choices': ['۱/۲', '۲', '۳', '۴'], 'correct': 0}]]})
self.assertEqual(problems, [])
self.q.refresh_from_db()
self.assertEqual(self.q.prompt, '۱/۲ کدام است؟')
self.assertEqual([c.value for c in self.q.choices.order_by('id')], ['۱/۲', '۲', '۳', '۴'])
self.assertEqual([c.pk for c in self.q.choices.order_by('id')], [c.pk for c in self.c])
self.assertTrue(UserAnswer.objects.filter(pk=self.answer.pk, choice=self.c[0]).exists())
def test_choice_count_mismatch_is_reported_not_applied(self):
problems = apply_fixups.apply_db({'question_set': [[PATH, '(۱)/(۲) کدام است؟', {'choices': ['۱', '۲']}]]})
self.assertEqual(len(problems), 1)
self.assertEqual(self.c[0].__class__.objects.get(pk=self.c[0].pk).value, '(۱)/(۲)')
def test_lesson_fields_and_delete(self):
problems = apply_fixups.apply_db({
'lesson': [[PATH, {'example': 'مثالِ درست', 'figures': []}]],
'delete': [[PATH, 'تکراری']]})
self.assertEqual(problems, [])
self.lesson.refresh_from_db()
self.assertEqual((self.lesson.example, self.lesson.figures, self.lesson.steps), ('مثالِ درست', [], ['گام']))
self.assertFalse(Question.objects.filter(pk=self.dup.pk).exists())
def test_missing_targets_are_reported(self):
problems = apply_fixups.apply_db({'delete': [[PATH, 'نیست']], 'lesson': [[['x', 'y', 'z', 'w'], {'steps': []}]]})
self.assertEqual(len(problems), 2)
def test_unknown_field_stops(self):
with self.assertRaises(SystemExit):
apply_fixups.apply_db({'lesson': [[PATH, {'title': 'نه'}]]})

View File

@@ -11,7 +11,7 @@
| فایل | محتوا | | فایل | محتوا |
|---|---| |---|---|
| `curriculum.json` | ۱۰ پایه (سوم تا دوازدهم)، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس؛ با `subject`، متنِ درس، مثال، قدم‌ها و شکلِ درس (`figures`) | | `curriculum.json` | ۱۰ پایه (سوم تا دوازدهم)، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس؛ با `subject`، متنِ درس، مثال، قدم‌ها و شکلِ درس (`figures`) |
| `questions.json` | ۱۸٬۵۲۲ سوالِ چهارگزینه‌ای، بعضی با شکل (`figure`) | | `questions.json` | ۳۹٬۱۵۶ سوالِ چهارگزینه‌ای، بعضی با شکل (`figure`) |
هر فصل باید `subject` داشته باشد (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)؛ فایلِ بی‌آن رد می‌شود. شکل‌ها هر فصل باید `subject` داشته باشد (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)؛ فایلِ بی‌آن رد می‌شود. شکل‌ها
(`Lesson.figures`، `Question.figure`) با `rag.services.diagram.validate` در ورود اعتبارسنجی می‌شوند و شکلِ نامعتبر کلِ (`Lesson.figures`، `Question.figure`) با `rag.services.diagram.validate` در ورود اعتبارسنجی می‌شوند و شکلِ نامعتبر کلِ
@@ -180,3 +180,5 @@ python tools/figures/batch.py merge batch.json # افزود
اصلاحِ سوالِ منتشرشده: `tools/figures/apply_fixups.py` (حذف و خالی‌کردنِ عنوان، هم روی seed هم با `--db` روی سرور، اصلاحِ سوالِ منتشرشده: `tools/figures/apply_fixups.py` (حذف و خالی‌کردنِ عنوان، هم روی seed هم با `--db` روی سرور،
پس از `pg_dump`)؛ seed خودش چیزی را بازنویسی نمی‌کند. پس از `pg_dump`)؛ seed خودش چیزی را بازنویسی نمی‌کند.
فایل `tools/figures/server_fixups.json` اصلاح‌های سوال‌ها و درس‌هایی است که پیش از بازسازیِ پایه‌های ۳ تا ۱۲ منتشر شده بودند
(۹۳ حذف، ۵۸۵ اصلاحِ سوال، ۲۷۳۸ اصلاحِ درس)؛ پس از هر استقرارِ seed با `apply_fixups.py --db` روی سرور اجرا می‌شود.

View File

@@ -6,10 +6,13 @@
## دیپلوی ## دیپلوی
```bash ```bash
ssh -t administrator@78.38.35.249 \ ssh -t zareian@78.38.35.216 \
'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp' 'sudo -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"; sudo systemctl restart zareian-mathapp'
``` ```
`deploy.sh` با کاربرِ `mathapp` اجرا می‌شود که sudo ندارد، پس ری‌استارتِ خودش شکست می‌خورد و می‌گوید؛ ری‌استارت
همان دستورِ دوم است. فرانت: `sudo -u mathapp bash -c "cd /home/mathapp/mathapp_frontend_new && ./deploy.sh"`.
دو بار رمز می‌پرسد — یک‌بار برای `ssh` و یک‌بار برای `sudo`. **منتظرِ هر پرامپت دو بار رمز می‌پرسد — یک‌بار برای `ssh` و یک‌بار برای `sudo`. **منتظرِ هر پرامپت
بمانید**؛ چسباندنِ چند خط با هم، متنِ صف‌شده را به پرامپتِ رمز می‌دهد و بعد از بمانید**؛ چسباندنِ چند خط با هم، متنِ صف‌شده را به پرامپتِ رمز می‌دهد و بعد از
سه تلاش قفل می‌شوید. سه تلاش قفل می‌شوید.
@@ -40,7 +43,7 @@ poll کردنِ `/api/health`.
می‌شود، چون از داخلِ سرور آدرسِ عمومی از مسیرِ ArvanCloud برمی‌گردد و ۵۰۴/۰۰۰ می‌داد (۲۰۲۶-۱۰-۰۷). می‌شود، چون از داخلِ سرور آدرسِ عمومی از مسیرِ ArvanCloud برمی‌گردد و ۵۰۴/۰۰۰ می‌داد (۲۰۲۶-۱۰-۰۷).
- **اگر سرور به Gitea نمی‌رسد** (از سرور ۵۰۴، از لپ‌تاپ ۲۰۰): روی لپ‌تاپ - **اگر سرور به Gitea نمی‌رسد** (از سرور ۵۰۴، از لپ‌تاپ ۲۰۰): روی لپ‌تاپ
`git bundle create x.bundle <هشِ origin/main روی سرور>..main`، `scp` به `git bundle create x.bundle <هشِ origin/main روی سرور>..main`، `scp` به
`~/mathapp-v2/.deploy-backups/`، و روی سرور `/home/zareian/stage/` و `sudo install -o mathapp -m 600` به `/home/mathapp/mathapp-v2/.deploy-backups/`، و روی سرور (با `sudo -u mathapp`)
`DEPLOY_BUNDLE=.deploy-backups/x.bundle ./deploy.sh`. هشِ `origin/main` که چاپ می‌شود را با لپ‌تاپ `DEPLOY_BUNDLE=.deploy-backups/x.bundle ./deploy.sh`. هشِ `origin/main` که چاپ می‌شود را با لپ‌تاپ
مقایسه کنید. بدونِ این متغیر جایگزینی نیست و نرسیدن به Gitea دیپلو را می‌ایستاند. مقایسه کنید. بدونِ این متغیر جایگزینی نیست و نرسیدن به Gitea دیپلو را می‌ایستاند.
@@ -72,102 +75,75 @@ curl -i https://mathapi.tadrisino2.ir/api/health
## بکاپ و بازیابی ## بکاپ و بازیابی
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). دیتابیس Postgres 16 روی ۲۱۶ است (cluster `16/zareian`، پورت ۵۴۴۰، فقط localhost). **همه‌ی بکاپ‌ها روی خودِ ۲۱۶ رمز
می‌شوند و ۲۴۷ فقط فایلِ رمزشده می‌بیند**، چون ۲۴۷ مشترک است و هر کس `administrator` را دارد root است.
| لایه | چه چیزی | از دست رفتنِ داده در بدترین حالت | کجا | | لایه | چه چیزی | از دست رفتنِ داده در بدترین حالت | کجا |
|---|---|---|---| |---|---|---|---|
| کپیِ زنده (اصلی) | هر تغییرِ دیتابیس لحظه‌ای به سرورِ دیگر می‌رود + یک نسخه‌ی کامل در روز؛ بازیابی تا هر لحظه از ۷ روزِ اخیر | چند ثانیه‌ی آخر | `administrator@78.38.35.247:~/zareian-pgbackup/` | | pgBackRest (اصلی) | آرشیوِ پیوسته‌ی WAL، نسخه‌ی کامل یکشنبه‌ها ۰۳:۰۰، diff هر روز ۰۳:۴۰؛ دو نسخه‌ی کامل می‌ماند؛ `aes-256-cbc`؛ بازیابی تا هر لحظه | حدودِ ۱ دقیقه (`archive_timeout=60`) و تا ۱ دقیقه‌ی دیگر تا رسیدن به ۲۴۷ | `/var/lib/pgbackrest` روی ۲۱۶ و کپی در `~/zareian-pgbackrest-216/repo/` روی ۲۴۷ |
| پیش از migrate | `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمی‌شود)؛ برای برگشتن از migrationِ خراب | — | `~/mathapp-v2/.deploy-backups/db-before-deploy-*.pgdump` (۲ تای آخر) | | پیش از migrate | `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمی‌شود)؛ برای برگشتن از migrationِ خراب | — | `/home/mathapp/mathapp-v2/.deploy-backups/db-before-deploy-*.pgdump` (۲ تای آخر) |
| ایمیجِ کلِ ماشین | کارِ ادمینِ سرور، روزانه؛ ما محتوایش را نسنجیده‌ایم | — | دستِ ادمین | | ایمیجِ کلِ ماشین | کارِ ادمینِ سرور؛ ما محتوایش را نسنجیده‌ایم | — | دستِ ادمین |
| آرشیوِ دوره‌ی ۲۴۹ | ۷ نسخه‌ی کاملِ ۱۲ تا ۱۸ اکتبر، WAL، CSVهای پرداخت و لاگِ اپ از وقتی اپ روی ۲۴۹ بود؛ همه با age برای کلیدِ ۲۱۶ رمز شده‌اند | فقط برای مراجعه | `~/zareian-pgbackup/` روی ۲۴۷ (پایینِ همین بخش) |
بکاپِ شبانه‌ی جداگانه (`backup_db` با cron) حذف شد: کپیِ زنده همان کار را بهتر می‌کند و ایمیجِ روزانه را ادمین دارد. `tadrisino-offsite-sync` هر دقیقه `/var/lib/pgbackrest` و `/var/lib/tadrisino-backup/payment-log` را با `rsync --delete` به
۲۴۷ می‌فرستد. کلیدِ postgres در `authorized_keys`ِ ۲۴۷ با `from=192.168.200.163,restrict,command="rrsync -wo …"` فقط
می‌تواند در همان پوشه بنویسد (نه بخواند، نه شِل). `/api/health` → `offsiteBackup` از `pg_stat_archiver` و
`OFFSITE_SYNC_STATUS_FILE` می‌گوید آرشیو و sync چند ثانیه عقب‌اند.
### دفترِ خرید (`PaymentEvent`) روی هر دو سرور ### کلیدهای بازیابی
`/etc/tadrisino-backup/RECOVERY-KEYS` روی ۲۱۶ (root، 600): کلیدِ pgBackRest و کلیدِ خصوصیِ age
(`age-identity.txt` کنارش؛ `age-recipient.txt` کلیدِ عمومی است و رمز کردن با آن بی‌خطر است). **بی‌این‌ها هیچ
بکاپی روی ۲۴۷ خواندنی نیست**؛ یک نسخه بیرون از سرورها نزدِ صاحبِ محصول است (از ۲۰۲۶-۱۰-۱۸). اگر کلیدها عوض شدند، نسخه‌ی تازه را
او در ترمینالِ خودش برمی‌دارد: `ssh -t zareian@78.38.35.216 sudo cat /etc/tadrisino-backup/RECOVERY-KEYS` (نه در چت).
### دفترِ خرید (`PaymentEvent`)
هر قدمِ مسیرِ خرید یک ردیف در جدولِ `payment_paymentevent` می‌نویسد (`payment/events.py`): `order_created`، هر قدمِ مسیرِ خرید یک ردیف در جدولِ `payment_paymentevent` می‌نویسد (`payment/events.py`): `order_created`،
`discount_applied`، `gateway_redirect`، `gateway_request_failed`، `callback_received`، `callback_unknown_authority`، `discount_applied`، `gateway_redirect`، `gateway_request_failed`، `callback_received`، `callback_unknown_authority`،
`payment_result` (همه‌ی مسیرها: کال‌بک، بازسنجی، بازار، تمدیدِ بازار، کدِ ۱۰۰٪؛ با `ignored` برای تکراری/جعلی)، `payment_result` (همه‌ی مسیرها: کال‌بک، بازسنجی، بازار، تمدیدِ بازار، کدِ ۱۰۰٪؛ با `ignored` برای تکراری/جعلی)،
`bazaar_start`، `admin_cancel`، `admin_refund`. توکنِ خریدِ بازار در آن نمی‌رود. ادمین: «Payment events» (فقط‌خواندنی). `bazaar_start`، `admin_cancel`، `admin_refund`. توکنِ خریدِ بازار در آن نمی‌رود. ادمین: «Payment events» (فقط‌خواندنی).
چون جدول در همان Postgres است با WAL لحظه‌ای به ۲۴۷ می‌رسد. روی ۲۴۷، `zareian-pg-export-events.timer` جدا از بکاپِ دیتابیس، `tadrisino-export-payment-events.timer` (۰۱:۱۵، ۰۷:۱۵، ۱۳:۱۵، ۱۹:۱۵) سه CSVِ تجمعی
(۰۱:۱۵، ۰۷:۱۵، ۱۳:۱۵، ۱۹:۱۵) با `bin/export-payment-events.sh` از روی همان بکاپ (نمونه‌ی موقت روی سوکتِ داخلِ پوشه، (`payment-events-*`، `orders-*`، `transactions-*`) را مستقیم با age رمز می‌کند و در `/var/lib/tadrisino-backup/payment-log/`
بی‌اتصال به ۲۴۹ یا Postgresِ دیگران) سه CSVِ تجمعی در `~/zareian-pgbackup/payment-log/` می‌نویسد: می‌گذارد؛ ۱۴ تای آخرِ هر نوع می‌ماند و با sync به ۲۴۷ می‌رود. متنِ خوانا هیچ‌جا ذخیره نمی‌شود. باز کردن:
`payment-events-*`، `orders-*`، `transactions-*`؛ ۱۴ تای آخرِ هر نوع می‌ماند. شکستش در `age -d -i age-identity.txt payment-events-….csv.age`.
`journalctl -u zareian-pg-export-events`ِ ۲۴۷. زیرِ ۳ گیگ فضای آزاد اجرا نمی‌شود.
### لاگِ برنامه (`app.log`): برای همیشه روی ۲۴۷ ### لاگِ برنامه (`app.log`)
با `LOG_FILE_ROTATION=external` در `.env`ِ ۲۴۹ برنامه لاگ را پاک نمی‌کند (`WatchedFileHandler`). هر شب ۰۰:۰۵ با `LOG_FILE_ROTATION=external` برنامه لاگ را پاک نمی‌کند (`WatchedFileHandler`). هر شب ۰۰:۰۵
`zareian-applog-archive.timer` (۲۴۹) با `ops/applogs/archive-app-log.sh` فایل را به `logs/archive/app-<UTC>.log.gz` `zareian-applog-archive.timer` با `ops/applogs/archive-app-log.sh` فایل را به `logs/archive/app-<UTC>.log.gz` می‌برد و
می‌برد و فشرده می‌کند؛ هر ساعت `zareian-applog-pull.timer` (۲۴۷) با `bin/pull-app-logs.sh` فایل‌های تازه را به فشرده می‌کند. **روی ۲۱۶ این آرشیو هنوز به ۲۴۷ نمی‌رود**؛ راهِ پیشنهادی: رمز کردن با age و افزودنِ `logs/archive` به
`~/zareian-pgbackup/app-logs/` می‌آورد (`gzip -t` پیش از نگه‌داشتن) و **هرگز پاک نمی‌کند** (تصمیمِ مالک ۲۰۲۶-۱۰-۱۳). `tadrisino-offsite-sync` (همان rrsync، پوشه‌ی `app-logs/`). حجم (برآورد): با ۶۰۰ کاربرِ فعال ۲ تا ۳ مگِ فشرده در روز.
روی ۲۴۹ فقط ۳۰ روز می‌ماند و فقط فایلی پاک می‌شود که ۲۴۷ گرفتنش را تأیید کرده (`.shipped`). اگر آرشیوِ شبانه بخوابد و `app.log` از ۲۵۶ مگ بگذرد، `/api/health` هشدار می‌دهد. خواندن: `zcat app-*.log.gz | grep user_id=26`.
دسترسی: کلیدِ `~/.ssh/zareian_applogs`ِ ۲۴۷ در `authorized_keys`ِ ۲۴۹ با
`restrict,command="…/ops/applogs/serve-archive.sh"` است: فقط `list`، `get NAME`، `ack NAME`؛ شِل و پورت ندارد
(سنجیده: `id` و `cat /etc/passwd` → `bad request`). بکاپِ `authorized_keys` پیش از افزودن کنارش است.
حجم (برآورد، نه سنجش): لاگِ JSON حدودِ ۱۵ برابر فشرده می‌شود (۷۰۰ک → ۴۰ک در اولین اجرا)؛ با ۶۰۰ کاربرِ فعال
حدودِ ۳۰ مگِ خام و ۲ تا ۳ مگِ فشرده در روز، یعنی حدودِ ۱ گیگ در سال روی ۲۴۷. اگر آرشیوِ شبانه بخوابد و
`app.log` از ۲۵۶ مگ بگذرد، `/api/health` هشدار می‌دهد. خواندن: `zcat app-*.log.gz | grep user_id=26`.
### کپیِ زنده چطور کار می‌کند
Postgres هر تغییر را اول در یک دفترِ پیوسته (WAL) می‌نویسد. سرورِ ۲۴۷ این دفتر را با ابزارِ خودِ Postgres
(`pg_receivewal`) لحظه‌ای می‌گیرد، نه کپیِ کلِ دیتابیس: فقط تغییرات می‌روند. روزی یک بار هم یک نسخه‌ی کامل
(`pg_basebackup`) گرفته می‌شود؛ ۷ نسخه‌ی آخر می‌مانند و WALِ قدیمی‌تر از قدیمی‌ترینشان پاک می‌شود، پس حجم
محدود است. بازیابی = آخرین نسخه‌ی کاملِ پیش از لحظه‌ی دلخواه + بازپخشِ WAL تا آن لحظه.
```
۲۴۹ (اپ) ۲۴۷ (بکاپ)
Postgres 5440 (localhost) ◄── تونلِ SSH ─── zareian-pg-tunnel (127.0.0.1:15440)
اسلاتِ zareian_247 zareian-pg-wal → wal/ (لحظه‌ای)
نقشِ zareian_replicator zareian-pg-basebackup.timer → base/ (هر روز ۰۴:۳۰، ۷ تا)
```
- **۲۴۹ را نمی‌خواباند:** اپ منتظرِ ۲۴۷ نمی‌ماند (تکثیرِ ناهمزمان). اگر ۲۴۷ قطع شود، اسلات تغییرات را نگه می‌دارد تا
وصل شود، ولی حداکثر ۱ گیگ (`max_slot_wal_keep_size`)؛ بیش از آن اسلات «lost» می‌شود و دیسکِ ۲۴۹ امن می‌ماند.
نسخه‌ی کامل با `--checkpoint=spread --max-rate=20M` گرفته می‌شود.
- **امنیت:** کلیدِ SSHِ ۲۴۷ در `authorized_keys`ِ ۲۴۹ با `restrict,port-forwarding,permitopen="127.0.0.1:5440"`
محدود است (نه شِل، نه پورتِ دیگر). رمزِ نقشِ `zareian_replicator` فقط در `~/.pgpass`ِ ۲۴۷ است. این نقش
همه‌ی داده را می‌خواند (طبیعتِ بکاپ)، پس `~/zareian-pgbackup` با مجوز ۷۰۰ است. ترافیک در شبکه‌ی داخلی است
(۲۴۹ = `192.168.200.164`، ۲۴۷ = `192.168.200.167`).
- **Postgresِ ۲۴۷ را به کار نمی‌گیریم:** سرویسِ `postgresql@14-main` آن‌جا مالِ دیگران است. ما فقط ابزارهای
Postgres 14 را اجرا می‌کنیم؛ آزمونِ بازیابی یک Postgresِ موقت روی سوکتِ داخلِ پوشه‌ی خودمان بالا می‌آورد.
- **پایش:** `/api/health` (با `X-Internal-Key`) زیرِ `offsiteBackup` وضعیت و عقب‌ماندگی را می‌دهد و اگر ۲۴۷
وصل نباشد، عقب باشد یا اسلات lost شده باشد هشدار می‌دهد (`OFFSITE_WAL_SLOT=zareian_247` در `.env`ِ ۲۴۹).
شکستِ نسخه‌ی کاملِ روزانه از ۲۴۹ دیده نمی‌شود: روی ۲۴۷ `systemctl status zareian-pg-basebackup` و
`journalctl -u zareian-pg-basebackup`. شکستش داده را از دست نمی‌دهد (WAL از آخرین نسخه‌ی سالم نگه داشته
می‌شود)، فقط بازیابی را کندتر می‌کند.
فایل‌ها در `ops/pgbackup/` ریپو. روی ۲۴۷: یونیت‌ها در `/etc/systemd/system/zareian-pg-*`، اسکریپت‌ها در
`~/zareian-pgbackup/bin/`. هر دو با دست کپی می‌شوند (`deploy.sh` فقط ۲۴۹ را می‌شناسد)؛ بعد از تغییرِ
یکی از آن‌ها: کپی، `sudo systemctl daemon-reload`، و `restart` همان یونیت.
### آزمونِ بازیابی (ماهی یک بار) ### آزمونِ بازیابی (ماهی یک بار)
روی ۲۴۷: `~/zareian-pgbackup/bin/restore-check.sh` (یا با یک لحظه: `… '2026-10-10 16:30:00+03:30'`). `sudo -u postgres tadrisino-restore-check` روی ۲۱۶: آخرین بکاپ را در پوشه‌ی موقت بالا می‌آورد و شمارشِ
آخرین نسخه‌ی کامل + همه‌ی WAL را در پوشه‌ی موقت بالا می‌آورد، شمارش و آخرین زمانِ کاربر/ورود/سفارش/تراکنش/ کاربر/سؤال/سفارش/رویدادِ پرداخت/ورود را با دیتابیسِ زنده مقایسه می‌کند. آخرین اجرا: بعد از سوئیچ (۲۰۲۶-۱۰-۱۸)، همه برابر.
تغییرِ وضعیت را چاپ می‌کند و پاک می‌کند. همان پرسش را روی ۲۴۹ بزنید؛ باید یکی باشند (جز تغییرِ همان چند ثانیه).
### اگر ۲۴۷ مدتی قطع بود
`/api/health` می‌گوید `disconnected`. با برگشتنِ ۲۴۷ سرویس‌ها خودشان وصل می‌شوند و عقب‌ماندگی را می‌گیرند. اگر
`lost` شد (بیش از ۱ گیگ تغییر در قطعی): روی ۲۴۷
`systemctl stop zareian-pg-wal`، روی ۲۴۹ اسلات را بیندازید
(`SELECT pg_drop_replication_slot('zareian_247')`)، روی ۲۴۷ بسازید
(`pg_receivewal -h 127.0.0.1 -p 15440 -U zareian_replicator --create-slot --slot=zareian_247`)،
`systemctl start zareian-pg-wal` و `systemctl start zareian-pg-basebackup` (نسخه‌ی کاملِ تازه؛ WALِ پیش از آن
بریده است).
### بازیابیِ واقعی ### بازیابیِ واقعی
- **دیتابیسِ ۲۴۹ خراب شد یا داده‌ای اشتباهی پاک شد:** لحظه‌ی درست را پیدا کنید، روی ۲۴۷ با `restore-check.sh` - **داده‌ای اشتباهی پاک شد یا دیتابیس خراب شد:** اپ را stop، از وضعیتِ فعلی یک `pg_dump` بگیرید، و
تا آن لحظه آزمایش کنید؛ وقتی درست بود، با `KEEP=1` اجرا کنید تا Postgresِ موقت بماند، از آن با `pgbackrest --stanza=zareian --type=time --target='<لحظه>' restore` (با `sudo -u postgres`). این مسیر با زمانِ دلخواه
`pg_dump -Fc -h <پوشه> -U postgres zareian_mathapp` خروجی بگیرید و روی ۲۴۹ با `pg_restore --clean --no-owner -d zareian_mathapp` برگردد (اپ را پیش از آن stop کنید و از روی سرور آزموده نشده؛ با کمک بروید.
وضعیتِ فعلی هم یک `pg_dump` بگیرید). این مسیر روی سرور آزموده نشده؛ با کمک بروید. - **کلِ ۲۱۶ از دست رفت:** روی ماشینِ تازه پوشه‌ی `~/zareian-pgbackrest-216/repo/` را از ۲۴۷ از شبکه‌ی داخلی کپی کنید،
- **کلِ ۲۴۹ رفت:** همان بازیابی تا انتهای WAL روی ماشینِ تازه، و سفارش‌های چند ثانیه‌ی آخر را با پنلِ زرین‌پال/بازار `pgbackrest.conf` را از `ops/server216/` با کلید از RECOVERY-KEYS بسازید و `pgbackrest --stanza=zareian restore`.
تطبیق دهید. بازیابیِ کامل از نسخه‌ی رمزشده‌ی ۲۴۷ یک بار آزموده شده (دیتابیس و CSV). سفارش‌های دقیقه‌ی آخر را با پنلِ
زرین‌پال/بازار تطبیق دهید.
### آرشیوِ دوره‌ی ۲۴۹ روی ۲۴۷
`~/zareian-pgbackup/` روی ۲۴۷ بکاپ‌های زمانی است که اپ روی ۲۴۹ بود (کپیِ زنده با `pg_receivewal` تا ۲۰۲۶-۱۰-۱۸). بعد
از سوئیچ هر فایل با age برای همان کلیدِ ۲۱۶ رمز و نسخه‌ی خوانا `shred` شد؛ نام‌ها و تاریخ‌ها همان است:
`base/<تاریخ>.tar.age` (هر کدام یک `pg_basebackup` با قالبِ plain)، `wal/*.age`، `payment-log/*.csv.age`،
`app-logs/*.log.gz.age`، و فهرستِ چک‌سام‌ها `bin/manifest.txt.age`. یونیت‌های `zareian-pg-*` و `zareian-applog-pull` روی
۲۴۷ disable شده‌اند و اسکریپت‌های `bin/` دیگر کاری نمی‌کنند.
خواندن فقط روی ۲۱۶ (که کلید را دارد) و از شبکه‌ی داخلی، نه از لپ‌تاپ:
`sshpass -f /home/zareian/.pw247 ssh administrator@192.168.200.167 'cat ~/zareian-pgbackup/base/<تاریخ>.tar.age' | sudo age -d -i /etc/tadrisino-backup/age-identity.txt | tar -x -C <پوشه>`.
درستیِ رمزنگاری با باز کردنِ دو نسخه‌ی کامل و سه فایلِ نمونه روی ۲۱۶ سنجیده شد. پاک کردنِ این پوشه تصمیمِ
صاحبِ محصول است.
## متغیرهای محیطی ## متغیرهای محیطی
@@ -332,37 +308,63 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
## سرور ## سرور
`mathapi.tadrisino2.ir` روی یک **ماشینِ مشترکِ شرکت** اجرا می‌شود (`administrator@78.38.35.249`)، نه سروری که مالِ این `math.tadrisino2.ir` و `mathapi.tadrisino2.ir` از **۲۰۲۶-۱۰-۱۸** روی ماشینِ مجازیِ اختصاصیِ `78.38.35.216` اجرا می‌شوند
پروژه باشد. روی همان ماشین Gitea، ایمیل، DNS، چند دیتابیس و اپ‌های تیم‌های دیگر در حالِ کارند، و حسابِ ما `sudo`ِ (ArvanCloud → 216:443). کلِ ماشین مالِ ماست. قواعدِ کار روی سرورها، با فهرستِ آنچه روی ۲۴۹ و ۲۴۷ مالِ ما نیست،
کامل دارد؛ تنها مانع میانِ یک فرمانِ بی‌دقت و داده‌ی کلِ شرکت، خودِ اجراکننده است. سرورِ بکاپ (`78.38.35.247`) هم در `CLAUDE.md` «Working on the servers» است.
مشترک است.
مالِ ما روی ۲۴۹: پوشه‌های `~/mathapp-v2` (بک‌اند) و `~/mathapp_frontend_new` (فرانت)، سرویسِ `gunicorn-mathapp` با | | |
نامِ دومِ **`zareian-mathapp`** (همیشه همین نام را بنویسید)، `zareian-redis`، clusterِ Postgresِ `14/zareian`، تایمرِ |---|---|
`zareian-applog-archive`، و دو سایت و دو snippetِ nginx (بخشِ بعد). روی ۲۴۷ فقط `~/zareian-pgbackup/` و یونیت‌های | ماشین | VMware، Ubuntu 24.04، ۸ هسته (Xeon E5-2690 v3)، ۱۲ گیگ رم، دیسکِ ۹۷ گیگ؛ hostname `mathapp`، IPِ داخلی `192.168.200.163`، IPِ خروجی `78.38.35.228` |
`zareian-*`. | ورود | دو حساب با sudo: `zareian` (صاحبِ محصول) و `mathapp-admin` (مشترکِ سینیور و صاحبِ شرکت)؛ هر دو با رمزِ خطِ `AdminUser-Password` فایلِ رمز. ورودِ root بسته، `AllowUsers zareian mathapp-admin`، forwarding بسته (`10-tadrisino.conf`)، `LoginGraceTime 2m` |
| اپ | کاربرِ بی‌sudoِ `mathapp`؛ `/home/mathapp/mathapp-v2` و `/home/mathapp/mathapp_frontend_new` |
| سرویس‌ها | `zareian-mathapp` (= `gunicorn-mathapp`، با `RuntimeDirectory`)، `zareian-redis` (6390)، `postgresql@16-zareian` (5440)، `nginx`، تایمرهای `tadrisino-*` و `zareian-applog-archive` |
| فایل‌ها | نسخه‌ی اصلیِ همه‌ی اسکریپت‌ها و تنظیم‌های این سرور در `ops/server216/` (بی‌کلید). تغییر: فایلِ ریپو، بعد کپی روی سرور |
| رمزها | رمزِ Postgres و Redis مالِ همین سرور است (`.pg_password`، `.redis_password` کنارِ `.env`) |
| فایروال | ufw فقط 22/80/443؛ فایروالِ شرکت هر سه را باز کرده |
| ضدِ ربات | ۱) `tadrisino-blocklist.timer`: هر روز FireHOL level1 (Spamhaus، DShield، …) در ipsetِ `tadrisino-block`، همه‌ی پورت‌ها؛ شبکه‌ی داخلی، `78.38.35.0/24` و رنج‌های آروان هرگز در آن نیستند. ۲) `ufw limit 22`: بیش از ۶ اتصال در ۳۰ ثانیه رد. ۳) fail2ban پله‌ای: ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه، هر تکرار دو برابر تا یک هفته؛ `recidive`: ۵ بار در روز → یک هفته همه‌ی پورت‌ها؛ `192.168.200.0/24` هرگز. آزاد کردن: `sudo fail2ban-client set sshd unbanip <IP>` |
| بسته‌ها | apt از `mirror.arvancloud.ir`، pip از `mirror-pypi.runflare.com` (`~mathapp/.config/pip/pip.conf`)، npm از `mirror-npm.runflare.com`؛ `files.pythonhosted.org` و `registry.npmjs.org` از این سرور بسته‌اند |
| شبکه‌ی داخلی | ۲۴۹ = `192.168.200.164`، ۲۴۷ = `.167`. انتقالِ فایلِ بزرگ بینِ سرورها از همین مسیر (اینترنتِ لپ‌تاپ کند است) |
```bash ```bash
sudo systemctl restart zareian-mathapp # مالِ ما sudo systemctl restart zareian-mathapp # اپ
sudo systemctl restart gunicorn # مالِ ما نیست — اپِ تیمِ دیگر sudo nginx -t && sudo systemctl reload nginx
``` ```
قواعدی که قابلِ مذاکره نیستند (نسخه‌ی کامل، با فهرستِ آنچه مالِ ما نیست و رویه‌ی دیپلو، در `CLAUDE.md` «Working on **سنجیده بعد از سوئیچ (۲۰۲۶-۱۰-۱۸):** ۴۴۳ از بیرون باز است و درخواستِ مستقیم به IP ۴۰۳ می‌گیرد؛ درخواست‌های هر دو دامنه
the servers»): از مسیرِ آروان به ۲۱۶ می‌رسند (در `access.log` با IPِ آروان)؛ `/api/health` با جزئیات از مسیرِ آروان ok و بی‌هشدار؛
دیتابیسِ ۲۴۹ با همان شمارش‌ها منتقل شد؛ بکاپِ کاملِ تازه و آزمونِ بازیابی برابر. صاحبِ محصول از گوشی وارد شد و صفحه‌ی
زرین‌پال باز شد. **نسنجیده:** پرداختِ کامل، خریدِ بازار، اپِ اندروید روی سرورِ تازه.
1. **اول فقط نگاه کنید**، بعد تغییر بدهید؛ هیچ فرمانِ مخربی بیرون از پوشه‌های ما، و مسیرها را کامل بنویسید. ### ۲۴۹ (کنار گذاشته، هنوز پاک نشده)
2. **`git clean` هرگز، و پروژه هرگز پاک و «از نو clone» نشود.** `.env`، `.django_secret_key`، لاگ‌ها و بکاپ‌ها در گیت
نیستند و همان‌جا زندگی می‌کنند؛ راهِ برگشت `git reset --hard origin/main` است. از ۲۰۲۶-۱۰-۱۸ اپ روی ۲۴۹ stop و disable است، اسلاتِ `zareian_247` برداشته شده و یونیت‌های بکاپِ آن روی ۲۴۷ خاموش‌اند.
3. **فقط `zareian-mathapp` ری‌استارت شود**؛ nginx مشترک است: `nginx -t` و بعد `reload`، هرگز `restart`. پوشه‌ها، cluster ِ `14/zareian` (با داده‌ی خوانای لحظه‌ی سوئیچ)، `zareian-redis` و سایت‌های nginxِ ما آن‌جا مانده‌اند.
4. **پیش از ویرایشِ هر چیز زیرِ `/etc` یا `.env` بکاپِ تاریخ‌دار** در `~/mathapp-v2/.deploy-backups/`.
5. **`.env` فقط از راهِ Django خوانده شود**؛ `cat .env` نه، و مقادیرش در هیچ چتی paste نشود. - **برگشت** (تا وقتی ۲۱۶ نوشتنِ واقعی مثلِ پرداخت و ثبت‌نام نگرفته): سینیور originِ آروان را به ۲۴۹ برمی‌گرداند و روی ۲۴۹
6. **`df -h /` پیش از هر کارِ پرنوشت.** پر شدنِ دیسک ایمیل و Gitea را هم پایین می‌کشد. `sudo systemctl enable --now gunicorn-mathapp`. بعد از اولین نوشتنِ واقعی، اول dumpِ ۲۱۶ به ۲۴۹.
7. **از منابعِ تیم‌های دیگر استفاده نشود** (Redis، Postgres، صف، سایتِ nginx)؛ نیاز به آن‌ها درخواست از ادمینِ سرور است. - **پاک کردن** (حدودِ ۲۰۲۶-۱۰-۲۵، با تأییدِ صاحبِ محصول): cluster و پوشه‌های ما روی ۲۴۹، و بعد حذفِ
`_slot_state`/`OFFSITE_WAL_SLOT` از `config/health.py` و `settings.py`.
### چه چیزی منتظرِ دیگران است
- **سینیور:** برگرداندنِ originِ `qdrant.tadrisino2.ir` در آروان (بخشِ Qdrant)؛ private کردنِ مخزن در Gitea؛ ۱۲ هسته و رمِ
بیشتر (اختیاری؛ سرور ریبوت را تحمل می‌کند، تست شده).
- **صاحبِ محصول:** آزمونِ اپِ اندروید و `pay.html` روی سرورِ تازه.
### کارهای باقی‌مانده
- توکنِ فقط‌خواندنیِ Gitea برای `deploy.sh` (بعد از private شدنِ مخزن `git fetch` بی‌رمز کار نمی‌کند).
- `CORS_ALLOWED_ORIGINS` در `.env`ِ سرور هنوز `localhost:5174` دارد؛ حذفش.
- رمز کردن و فرستادنِ آرشیوِ لاگِ اپ به ۲۴۷ (بخشِ «لاگِ برنامه»).
- ورود با کلیدِ SSH برای `zareian` (صاحبِ محصول خواسته فعلاً بماند)، بعد `PasswordAuthentication no` با
`Match Address 192.168.200.0/24` برای ورودِ با رمز از شبکه‌ی داخلی.
- مرورِ کاملِ یک فهرستِ امنیتیِ استاندارد (OWASP Top 10 / API Top 10، CIS Benchmark، همراهِ
`docs/security-before-production.md`)، بعد لودتست روی ۲۱۶ (`tools/loadtest/README.md`).
## nginx (دو سایتِ ما) ## nginx (دو سایتِ ما)
نسخه‌ی اصلی در ریپو است: `ops/nginx/`. روی ۲۴۹: `sites-enabled/mathapp` (API، فایلِ معمولی)، نسخه‌ی اصلی در ریپو است: `ops/nginx/`. روی ۲۱۶: `sites-enabled/mathapp` (API، فایلِ معمولی)،
`sites-available/mathapp-front` (فرانت)، و دو snippet در `/etc/nginx/snippets/`: `sites-enabled/mathapp-front` (فرانت)، و دو snippet در `/etc/nginx/snippets/`:
| فایل | چه می‌کند | | فایل | چه می‌کند |
|---|---| |---|---|
@@ -370,8 +372,8 @@ the servers»):
| `zareian-front-headers.conf` | CSP، `X-Frame-Options`، `nosniff`، `Referrer-Policy`، `Permissions-Policy`، HSTS برای فرانت | | `zareian-front-headers.conf` | CSP، `X-Frame-Options`، `nosniff`، `Referrer-Policy`، `Permissions-Policy`، HSTS برای فرانت |
| `mathapp` | سقفِ بدنه ۲۰ مگابایت، HSTS | | `mathapp` | سقفِ بدنه ۲۰ مگابایت، HSTS |
تغییر: فایلِ ریپو را ویرایش و کامیت کنید؛ روی سرور فایلِ فعلی را در `.deploy-backups/` کپی کنید، `sudo install -m 644`، تغییر: فایلِ ریپو را ویرایش و کامیت کنید؛ روی سرور فایلِ فعلی را در `/home/mathapp/mathapp-v2/.deploy-backups/` کپی کنید، `sudo install -m 644`،
`sudo nginx -t`، و فقط اگر موفق بود `sudo systemctl reload nginx` (نه restart: سایت‌های دیگران روی همین nginx‌اند). `sudo nginx -t`، و فقط اگر موفق بود `sudo systemctl reload nginx` (reload درخواست‌های در جریان را نمی‌بُرد).
بعد از بیرون: هدرها با `curl -sI`، و درخواستِ مستقیم به IP باید ۴۰۳ بدهد. بعد از بیرون: هدرها با `curl -sI`، و درخواستِ مستقیم به IP باید ۴۰۳ بدهد.
**وقتی دیگران به این‌ها بخورند:** **وقتی دیگران به این‌ها بخورند:**
@@ -382,6 +384,37 @@ the servers»):
- سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بی‌ورود و با ورود، همه‌ی تب‌ها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید - سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بی‌ورود و با ورود، همه‌ی تب‌ها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید
(WebView همان CSP را می‌گیرد) و صفحه‌ی `pay.html` تا درگاه. (WebView همان CSP را می‌گیرد) و صفحه‌ی `pay.html` تا درگاه.
## Qdrant
**وضعِ فعلی Qdrant:** بردارهای RAG روی `https://qdrant.tadrisino2.ir` هستند (پشت ArvanCloud؛ سرورِ اصلی از بیرون پیدا
نیست و در فهرستِ سرورهای ما نیست. `rag_catalog` آن را سرورِ تیمِ دیگر می‌داند). نسخهٔ 1.17.0، ۳۴ collection، حدود
۱۰۸۸۰ نقطه، همه با بُعد ۱۵۳۶ و Cosine.
**هدف:** همهٔ کتاب‌های ریاضی‌ای که در Qdrant هست (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته؛ پایه‌های سوم تا دوازدهم) روی یک
Qdrant اختصاصی ببریم و اپ را به آن وصل کنیم، بدون ingest دوباره، چون ingest هزینهٔ GapGPT دارد.
**کدام collectionها (۱۵ تا، حدود ۵۶۸۰ نقطه):**
- ریاضی سوم، چهارم، پنجم، ششم، هفتم، هشتم، نهم، دهم.
- حسابان یازدهم، دوازدهم.
- هندسه دهم، یازدهم، دوازدهم.
- آمار و احتمال یازدهم.
- گسسته دوازدهم.
ریاضیِ سوم، ششم و هفتم را تیمِ دیگر ingest کرده، و صاحبِ محصول خواسته هر سه منتقل شوند. بقیهٔ collectionها (علوم، فارسی،
مطالعات اجتماعی، هدیهٔ آسمان، پیام‌های آسمان) در این برنامه نیستند.
**اصولِ انتقال:**
- از هر collection یک snapshot بگیرید، فایلِ آن را منتقل و روی Qdrant جدید بارگذاری کنید (بدون فراخوانیِ OpenAI).
- بعد از انتقال: تعدادِ نقاط، بُعد و Cosine یکی باشند؛ یک بردارِ ذخیره‌شده را به‌عنوانِ query بدهید و همان نقطه بالا بیاید.
- `/api/rag/ingest` هنوز روی Qdrant فعلی می‌نویسد. در بازهٔ کپی ingest متوقف شود، یا بعد از سوئیچ نقاطِ تازه دوباره همگام شوند.
- `QDRANT_URL` و `QDRANT_API_KEY` را فقط **بعد** از تأییدِ کپی عوض کنید؛ زودتر عوض شود، تیوتور جوابِ ناقص می‌دهد.
**خراب از سوئیچ (۲۰۲۶-۱۰-۱۸):** `qdrant.tadrisino2.ir` از مسیرِ آروان یک صفحه‌ی `400 Bad Request`ِ Django برمی‌گرداند، نه
Qdrant (هدرهای `referrer-policy: same-origin` و `cross-origin-opener-policy`؛ از ۲۱۶ و از لپ‌تاپ یکسان؛ درخواست به ۲۱۶ نمی‌رسد).
هر جستجوی دستیار شکست می‌خورد (`rag.retrieve: جستجوی Qdrant … شکست خورد` در `app.log`) و دانش‌آموز «سرویس در دسترس
نیست» می‌بیند. یعنی originِ این ساب‌دامین در آروان هم‌زمان با سوئیچ عوض شده؛ درستش با سینیور است. محلِ سرورِ اصلیِ
Qdrant را ما نمی‌دانیم. کد و `.env` عوض نشده‌اند. وارسی بعد از درست شدن: یک سوال از پی، و نبودنِ این خطا در `app.log`.
## لاگ‌ها ## لاگ‌ها
هر درخواستِ `/api/` به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌شود هر درخواستِ `/api/` به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌شود

View File

@@ -23,7 +23,7 @@
| ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد | | ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد |
| ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد | | ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد |
| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ شماره‌ی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد | | ۱۲ | هزینه‌ی دستیارِ AI با ساختِ شماره‌ی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور | | ۱۵ | دسترسیِ سرور: ورود با رمز از اینترنت، رمز در فایلِ متنی | متوسط | صاحبِ محصول |
| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | | ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت |
| ۲۴ | سقفِ روزانه‌ی پیامکِ کلِ سامانه (۲٬۰۰۰ روی سرور) را یک ناشناس پر می‌کند و ورود با پیامک برای همه بسته می‌شود | بالا (دسترس‌پذیری، هزینه) | تصمیمِ طراحی | | ۲۴ | سقفِ روزانه‌ی پیامکِ کلِ سامانه (۲٬۰۰۰ روی سرور) را یک ناشناس پر می‌کند و ورود با پیامک برای همه بسته می‌شود | بالا (دسترس‌پذیری، هزینه) | تصمیمِ طراحی |
| ۲۵ | با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | تصمیمِ طراحی | | ۲۵ | با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | تصمیمِ طراحی |
@@ -62,15 +62,14 @@
## ۱۵. دسترسیِ سرور ## ۱۵. دسترسیِ سرور
- **چیست:** ورود با رمز به حسابِ مشترکِ `administrator` با `sudo (ALL : ALL) ALL` و عضوِ - **چیست:** اپ روی سرورِ اختصاصیِ `78.38.35.216` است و فقط دو حساب با sudo دارد (`zareian` و `mathapp-admin`)؛ اپ با کاربرِ
`docker` و `lxd`، روی ماشینی که Gitea و ایمیلِ شرکت هم رویش است. رمز یک بار در گفتگو با بی‌sudoِ `mathapp` اجرا می‌شود و ورودِ root بسته است. ورود هنوز با رمز است و پورتِ ۲۲ از اینترنت باز (صاحبِ محصول از
agent گذاشته شده و روی دسکتاپِ صاحبِ پروژه در یک فایلِ متنی نگه داشته می‌شود. بیرونِ شرکت هم وارد می‌شود)؛ رمزها روی دسکتاپِ صاحبِ پروژه در یک فایلِ متنی‌اند. ۲۴۹ و ۲۴۷ (مشترکِ شرکت، حسابِ
- **خطر:** هر نشتِ رمز یعنی دسترسیِ root به کلِ ماشینِ شرکت، نه فقط این پروژه. `administrator` با sudoِ کامل) دیگر اپ را اجرا نمی‌کنند، ولی تا پاک شدنِ cluster ِ ۲۴۹ داده‌ی خوانای لحظه‌ی سوئیچ آن‌جاست.
۲۰۲۶-۱۰-۰۴: `PasswordAuthentication yes` در `sshd_config` و پورتِ ۲۲ از اینترنت باز است؛ پس رمز - **خطر:** نشتِ رمزِ `zareian` یعنی root روی سرورِ اپ. حدسِ رمز را FireHOL، `ufw limit` و fail2banِ پله‌ای کند می‌کنند
از هر جای اینترنت حدس‌زدنی است (fail2ban سنجیده نشد). (روزِ اول ~۵٬۴۰۰ تلاشِ ورود دیده شد).
- **رفع (ادمینِ سرور):** کلیدِ SSH به‌جای رمز، تعویضِ رمز، حسابِ جدا برای این پروژه با - **رفع:** کلیدِ SSH برای `zareian` و `PasswordAuthentication no` جز از شبکه‌ی داخلی (`docs/operations.md` «کارهای
دسترسیِ فقط به `~/mathapp-v2` و یک خطِ `NOPASSWD` فقط برای باقی‌مانده»)؛ جای فایلِ رمز Keychain؛ پاک کردنِ cluster و پوشه‌های ما روی ۲۴۹ بعد از دوره‌ی برگشت.
`systemctl restart zareian-mathapp`؛ پاک کردنِ فایلِ رمز از دسکتاپ (Keychain جایگزین است).
## ۱۶. نگه‌داریِ توکن در فرانت ## ۱۶. نگه‌داریِ توکن در فرانت
@@ -156,7 +155,7 @@
| قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (بی‌جواب‌ماندن و جزیره‌ی قفل جداگانه در ردیفِ بعدی بسته شده‌اند) | `content/tests_hearts_gate.py` | | قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (بی‌جواب‌ماندن و جزیره‌ی قفل جداگانه در ردیفِ بعدی بسته شده‌اند) | `content/tests_hearts_gate.py` |
| `select_for_update` مؤثر (قفلِ ردیفِ Postgres؛ روی SQLiteِ محلی `transaction_mode: IMMEDIATE`)؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` | | `select_for_update` مؤثر (قفلِ ردیفِ Postgres؛ روی SQLiteِ محلی `transaction_mode: IMMEDIATE`)؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` می‌گیرد (`pg_dump -Fc` روی Postgres) و اگر نشد migrate را اجرا نمی‌کند | مرحله‌ی «بکاپِ دیتابیس» در خروجیِ هر دیپلو | | `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` می‌گیرد (`pg_dump -Fc` روی Postgres) و اگر نشد migrate را اجرا نمی‌کند | مرحله‌ی «بکاپِ دیتابیس» در خروجیِ هر دیپلو |
| دیتابیس Postgresِ اختصاصی است و هر تغییرش لحظه‌ای روی سرورِ دیگری (۲۴۷) کپی می‌شود، با نسخه‌ی کاملِ روزانه و بازیابی تا هر لحظه از ۷ روزِ اخیر؛ قطعیِ ۲۴۷ اپ را نمی‌خواباند و بیش از ۱ گیگ روی دیسکِ ۲۴۹ جمع نمی‌شود (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: بازیابی روی ۲۴۷ با شمارش‌های برابرِ ۲۴۹ و یک ردیفِ آزمایشی که ۲ ثانیه بعد آن‌جا بود | | دیتابیس روی سرورِ اختصاصی (۲۱۶) با pgBackRest بکاپ می‌شود: آرشیوِ پیوسته‌ی WAL (حداکثر ~۱ دقیقه عقب)، نسخه‌ی کاملِ هفتگی و diffِ روزانه، **رمزشده روی خودِ ۲۱۶**؛ کپی هر دقیقه به ۲۴۷ با کلیدِ فقط‌نوشتنی، پس ۲۴۷ (مشترک) هیچ داده‌ی خوانایی ندارد. آرشیوِ دوره‌ی ۲۴۹ روی ۲۴۷ هم رمز شد (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: `tadrisino-restore-check` بعد از سوئیچ با شمارش‌های برابر، بازیابیِ کامل از نسخه‌ی رمزشده‌ی ۲۴۷، و باز کردنِ نمونه‌های آرشیوِ ۲۴۹ با چک‌سامِ برابر |
| `.env`، کلیدِ امضا، لاگ‌ها و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | روی سرور `sudo -u nobody ls ~/mathapp-v2/.deploy-backups` باید خطای دسترسی بدهد | | `.env`، کلیدِ امضا، لاگ‌ها و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | روی سرور `sudo -u nobody ls ~/mathapp-v2/.deploy-backups` باید خطای دسترسی بدهد |
| پانزده مسیرِ خواندنی و نوشتنیِ کم‌خطر سقفِ نرخ دارند (بانکِ سوال و intro بی‌ورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلن‌ها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ می‌گیرد | `content/tests_read_throttles.py` | | پانزده مسیرِ خواندنی و نوشتنیِ کم‌خطر سقفِ نرخ دارند (بانکِ سوال و intro بی‌ورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلن‌ها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ می‌گیرد | `content/tests_read_throttles.py` |
| ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) | | ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) |

View File

@@ -20,7 +20,7 @@
|---|---| |---|---|
| پایه‌های سوم تا دوازدهم | **۱۰ پایه** | | پایه‌های سوم تا دوازدهم | **۱۰ پایه** |
| کتاب (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) | **۱۵ کتاب در ۵ درس** | | کتاب (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) | **۱۵ کتاب در ۵ درس** |
| سؤالِ چهارگزینه‌ای | **۱۸٬۵۲۲** | | سؤالِ چهارگزینه‌ای | **۲۲٬۶۰۲** |
| عملیاتِ API، مستند در Swagger | **۶۱** | | عملیاتِ API، مستند در Swagger | **۶۱** |
| تستِ خودکار، همه سبز | **۹۷۳** | | تستِ خودکار، همه سبز | **۹۷۳** |
| اپِ جنگو، هرکدام یک مسئولیت | **۱۴** | | اپِ جنگو، هرکدام یک مسئولیت | **۱۴** |
@@ -55,7 +55,7 @@
### واحدهای محتوا (این واژه‌ها در کلِ سند تکرار می‌شوند) ### واحدهای محتوا (این واژه‌ها در کلِ سند تکرار می‌شوند)
پایه Grade ۱۰ ← کتاب Subject ۵ ← فصل Unit ۸۹ ← جزیره Node ۱٬۱۴۵ ← درس Lesson ۵٬۴۰۶ ← سؤال Question ۱۸٬۵۲۲ ← گزینه Choice پایه Grade ۱۰ ← کتاب Subject ۵ ← فصل Unit ۸۹ ← جزیره Node ۱٬۱۴۵ ← درس Lesson ۵٬۴۰۶ ← سؤال Question ۲۲٬۶۰۲ ← گزینه Choice
**«درس» در این پروژه یعنی یک «تیپ‌مسئله»ی کتاب**، نه یک جلسه‌ی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ **جزیره** (Node) بازی می‌شود: آزمونِ هر جزیره از درس‌هایش سؤال برمی‌دارد. **«درس» در این پروژه یعنی یک «تیپ‌مسئله»ی کتاب**، نه یک جلسه‌ی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ **جزیره** (Node) بازی می‌شود: آزمونِ هر جزیره از درس‌هایش سؤال برمی‌دارد.
@@ -732,7 +732,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| فایل | محتوا | | فایل | محتوا |
|---|---| |---|---|
| `content/seed/curriculum.json` | ۱۰ پایه، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس، با مثال، قدم‌ها و شکلِ درس | | `content/seed/curriculum.json` | ۱۰ پایه، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس، با مثال، قدم‌ها و شکلِ درس |
| `content/seed/questions.json` | ۱۸٬۵۲۲ سؤالِ چهارگزینه‌ای، بعضی با شکل | | `content/seed/questions.json` | ۲۲٬۶۰۲ سؤالِ چهارگزینه‌ای، بعضی با شکل |
- `seed_content` در هر انتشار اجرا می‌شود؛ **هیچ‌وقت چیزی پاک نمی‌کند** و اجرای دوباره‌اش بی‌خطر است. - `seed_content` در هر انتشار اجرا می‌شود؛ **هیچ‌وقت چیزی پاک نمی‌کند** و اجرای دوباره‌اش بی‌خطر است.
- سؤال با **مسیرِ عنوان** (پایه › فصل › جزیره › درس) به درس وصل می‌شود، نه با شناسه — چون شناسه‌ها بینِ دو دیتابیس فرق دارند. - سؤال با **مسیرِ عنوان** (پایه › فصل › جزیره › درس) به درس وصل می‌شود، نه با شناسه — چون شناسه‌ها بینِ دو دیتابیس فرق دارند.

View File

@@ -1,11 +1,12 @@
#!/bin/bash #!/bin/bash
# روی ۲۴۹، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ می‌برد و فشرده می‌کند. # روی سرورِ اپ، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ می‌برد و فشرده می‌کند.
# برنامه با LOG_FILE_ROTATION=external از WatchedFileHandler استفاده می‌کند و پس از جابه‌جایی خودش app.log تازه # برنامه با LOG_FILE_ROTATION=external از WatchedFileHandler استفاده می‌کند و پس از جابه‌جایی خودش app.log تازه
# می‌سازد؛ خطی که در همان لحظه نوشته شود در فایلِ جابه‌جاشده می‌ماند، گم نمی‌شود. # می‌سازد؛ خطی که در همان لحظه نوشته شود در فایلِ جابه‌جاشده می‌ماند، گم نمی‌شود.
# این‌جا فقط ۳۰ روز می‌ماند (دیسکِ ۲۴۹ پر است) و فقط فایلی پاک می‌شود که ۲۴۷ گرفتنش را تأیید کرده # این‌جا فقط ۳۰ روز می‌ماند (دیسکِ ۲۴۹ پر است) و فقط فایلی پاک می‌شود که ۲۴۷ گرفتنش را تأیید کرده
# (`.shipped`)؛ نسخه‌ی همیشگی روی ۲۴۷ است (pull-app-logs.sh). فقط زیرِ ~/mathapp-v2/logs می‌نویسد. # (`.shipped`)؛ نسخه‌ی همیشگی روی ۲۴۷ است (pull-app-logs.sh). فقط زیرِ ~/mathapp-v2/logs می‌نویسد.
set -euo pipefail set -euo pipefail
LOGS=/home/administrator/mathapp-v2/logs # پوشه‌ی پروژه از جای خودِ اسکریپت (ops/applogs/ → دو پوشه بالاتر)، تا روی هر سروری با هر کاربری درست باشد.
LOGS="$(cd "$(dirname "$0")/../.." && pwd)/logs"
ARCH="$LOGS/archive" ARCH="$LOGS/archive"
mkdir -p "$ARCH" mkdir -p "$ARCH"
chmod 750 "$ARCH" chmod 750 "$ARCH"

View File

@@ -5,7 +5,7 @@ server {
server_name math.tadrisino2.ir; server_name math.tadrisino2.ir;
ssl_certificate /etc/ssl/certs/mathapp.crt; ssl_certificate /etc/ssl/certs/mathapp.crt;
ssl_certificate_key /etc/ssl/private/mathapp.key; ssl_certificate_key /etc/ssl/private/mathapp.key;
root /home/administrator/mathapp_frontend_new/dist; root /home/mathapp/mathapp_frontend_new/dist;
include /etc/nginx/snippets/zareian-arvan-only.conf; include /etc/nginx/snippets/zareian-arvan-only.conf;
include /etc/nginx/snippets/zareian-front-headers.conf; include /etc/nginx/snippets/zareian-front-headers.conf;

View File

@@ -0,0 +1,13 @@
# تدریسینو: سخت‌سازیِ SSH. فقط zareian (صاحبِ محصول) و mathapp-admin (حسابِ مشترکِ سینیور و صاحبِ شرکت) وارد می‌شوند.
# ورود با رمز هنوز باز است (تا رفتن به کلید SSH)؛ fail2ban جلوی حدس زدن را می‌گیرد.
PermitRootLogin no
AllowUsers zareian mathapp-admin
MaxAuthTries 4
MaxSessions 10
LoginGraceTime 2m
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
ClientAliveInterval 300
ClientAliveCountMax 2

View File

@@ -0,0 +1,18 @@
# تدریسینو: تنظیماتِ شبکه‌ی استاندارد (CIS). سرور مسیریاب نیست.
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
fs.protected_hardlinks = 1
fs.protected_symlinks = 1

View File

@@ -0,0 +1,4 @@
# تدریسینو: آرشیوِ پیوسته‌ی WAL با pgBackRest (رمزنگاری‌شده). archive_timeout یعنی حداکثر ۶۰ ثانیه تغییرِ آرشیونشده.
archive_mode = on
archive_command = 'pgbackrest --stanza=zareian archive-push %p'
archive_timeout = 60

View File

@@ -0,0 +1,20 @@
[Unit]
Description=Gunicorn MathApp (tadrisino API)
After=network.target postgresql@16-zareian.service zareian-redis.service
Wants=postgresql@16-zareian.service zareian-redis.service
[Service]
User=mathapp
Group=www-data
WorkingDirectory=/home/mathapp/mathapp-v2
EnvironmentFile=/home/mathapp/mathapp-v2/.env
# /run/gunicorn بعد از هر ریبوت ساخته می‌شود (روی ۲۴۹ دستی ساخته شده بود و بعد از ریبوت نبود).
RuntimeDirectory=gunicorn
RuntimeDirectoryMode=0750
ExecStart=/home/mathapp/mathapp-v2/venv/bin/gunicorn --bind unix:/run/gunicorn/mathapp.sock config.wsgi:application
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Alias=zareian-mathapp.service

View File

@@ -0,0 +1,17 @@
# روی ۲۱۶: /etc/pgbackrest.conf (root:postgres 640). کلیدِ واقعی فقط روی سرور و در /etc/tadrisino-backup/RECOVERY-KEYS است.
# تدریسینو: بکاپِ رمزنگاری‌شده. مخزنِ محلی /var/lib/pgbackrest؛ نسخه‌ی بیرونی با tadrisino-offsite-sync به ۲۴۷ (فقط فایلِ رمزشده).
[global]
repo1-path=/var/lib/pgbackrest
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<RECOVERY-KEYS، خطِ کلیدِ ۱>
repo1-retention-full=2
compress-type=zst
process-max=2
start-fast=y
log-level-console=info
log-level-file=detail
[zareian]
pg1-path=/var/lib/postgresql/16/zareian
pg1-port=5440
pg1-socket-path=/var/run/postgresql

View File

@@ -0,0 +1,44 @@
#!/bin/bash
# تدریسینو: IPهای شناخته‌شده‌ی مخرب را پیش از اولین تلاش روی همه‌ی پورت‌ها می‌بندد.
# فهرست: FireHOL level1 (Spamhaus DROP/EDROP، DShield، Feodo، bogons)، روزی یک بار (tadrisino-blocklist.timer).
# شبکه‌های داخلی، رنجِ IPِ شرکت و رنج‌های ArvanCloud (از snippetِ nginx) هرگز بسته نمی‌شوند.
# اگر دانلود نشد، آخرین فهرستِ سالم (/var/lib/tadrisino-blocklist/list) دوباره بار می‌شود.
set -euo pipefail
URL=https://iplists.firehol.org/files/firehol_level1.netset
DIR=/var/lib/tadrisino-blocklist; SET=tadrisino-block
mkdir -p "$DIR"
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
if curl -fsS -m 90 "$URL" -o "$tmp" && [ "$(grep -c '^[0-9]' "$tmp")" -gt 1000 ]; then
python3 - "$tmp" "$DIR/list" <<'PY'
import ipaddress, re, sys
src, dst = sys.argv[1], sys.argv[2]
keep = ['127.0.0.0/8','10.0.0.0/8','172.16.0.0/12','192.168.0.0/16','100.64.0.0/10','169.254.0.0/16','78.38.35.0/24']
try:
keep += re.findall(r'^allow\s+([0-9./]+);', open('/etc/nginx/snippets/zareian-arvan-only.conf').read(), re.M)
except OSError:
pass
keep = [ipaddress.ip_network(k) for k in keep if ':' not in k]
out, dropped = [], 0
for line in open(src):
line = line.strip()
if not line or line[0] == '#':
continue
n = ipaddress.ip_network(line, strict=False)
if any(n.overlaps(k) for k in keep):
dropped += 1
continue
out.append(str(n))
open(dst, 'w').write('\n'.join(out) + '\n')
print(f'{len(out)} networks, {dropped} skipped (internal/company/Arvan)')
PY
else
echo "download failed; reloading last good list" >&2
fi
[ -s "$DIR/list" ] || { echo "no list available" >&2; exit 1; }
ipset create "$SET" hash:net maxelem 262144 -exist
ipset create "$SET-new" hash:net maxelem 262144 -exist
ipset flush "$SET-new"
sed "s/^/add $SET-new /" "$DIR/list" | ipset restore -exist
ipset swap "$SET-new" "$SET"; ipset destroy "$SET-new"
iptables -C INPUT -m set --match-set "$SET" src -j DROP 2>/dev/null || iptables -I INPUT 1 -m set --match-set "$SET" src -j DROP
echo "loaded $(ipset list "$SET" -t | sed -n 's/^Number of entries: //p') entries into $SET"

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Tadrisino: block known-malicious IPs (FireHOL level1)
After=network-online.target ufw.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/tadrisino-blocklist
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Tadrisino: refresh malicious-IP blocklist daily
[Timer]
OnCalendar=*-*-* 05:20:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,19 @@
#!/bin/bash
# تدریسینو: دفترِ خرید (payment_paymentevent)، سفارش‌ها و تراکنش‌ها به CSV، هر ۶ ساعت. هر فایل پیش از نوشته‌شدن روی دیسک
# با age برای کلیدِ /etc/tadrisino-backup/age-recipient.txt رمز می‌شود؛ متنِ خوانا هیچ‌جا ذخیره نمی‌شود.
# بازکردن: age -d -i age-identity.txt payment-events-….csv.age (کلید در RECOVERY-KEYS). ۱۴ فایلِ آخرِ هر نوع می‌ماند.
set -euo pipefail
OUT=/var/lib/tadrisino-backup/payment-log
REC=/etc/tadrisino-backup/age-recipient.txt
stamp=$(date -u +%Y%m%dT%H%M%SZ)
q() {
psql -p 5440 -d zareian_mathapp -v ON_ERROR_STOP=1 -Atqc "\\copy ($1) to stdout csv header" | age -R "$REC" -o "$OUT/$2-$stamp.csv.age"
}
q "select id, created_at, order_id, user_id, provider, event, detail from payment_paymentevent order by id" payment-events
q "select id, created_at, user_id, plan_id, plan_title, amount, status, provider, gateway_authority, subscription_id from payment_order order by id" orders
q "select id, created_at, order_id, gateway, gateway_ref_id, status, paid_at from payment_transaction order by id" transactions
chmod 640 "$OUT"/*-"$stamp".csv.age
for kind in payment-events orders transactions; do
ls -1t "$OUT/$kind"-*.csv.age | tail -n +15 | xargs -r rm -f --
done
echo "wrote $OUT/*-$stamp.csv.age"

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: encrypted payment CSVs every 6 hours
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/sbin/tadrisino-export-payment-events

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: encrypted payment CSVs every 6 hours
[Timer]
OnCalendar=*-*-* 01,07,13,19:15:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
#!/bin/bash
# تدریسینو: نسخه‌ی بیرونیِ بکاپ. فقط فایل‌های رمزشده (مخزنِ pgBackRest با AES-256 و CSVهای age) به ۲۴۷ می‌روند.
# کلیدِ postgres روی ۲۴۷ با rrsync -wo محدود است: فقط نوشتن در ~/zareian-pgbackrest-216، نه خواندن، نه شِل.
# پس از موفقیتِ هر دو، زمانش در offsite-synced-at ثبت می‌شود و /api/health از روی آن عقب‌ماندگی را می‌سنجد.
set -euo pipefail
R=administrator@192.168.200.167
SSH="ssh -i /var/lib/postgresql/.ssh/id_offsite -o BatchMode=yes -o ConnectTimeout=20"
rsync -a --delete -e "$SSH" /var/lib/pgbackrest/ "$R:repo/"
rsync -a --delete -e "$SSH" /var/lib/tadrisino-backup/payment-log/ "$R:payment-log/"
date -u +%s > /var/lib/tadrisino-backup/offsite-synced-at.tmp
mv /var/lib/tadrisino-backup/offsite-synced-at.tmp /var/lib/tadrisino-backup/offsite-synced-at

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: copy encrypted backups to 247
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/sbin/tadrisino-offsite-sync

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: copy encrypted backups to 247
[Timer]
OnCalendar=minutely
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: daily differential encrypted backup
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/bin/pgbackrest --stanza=zareian --type=diff backup

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: daily differential encrypted backup
[Timer]
OnCalendar=*-*-* 03:40:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: weekly full encrypted backup
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/bin/pgbackrest --stanza=zareian --type=full backup

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: weekly full encrypted backup
[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,26 @@
#!/bin/bash
# تدریسینو: آزمونِ بازیابی از بکاپِ رمزشده (ماهی یک بار، دستی: sudo -u postgres tadrisino-restore-check).
# آخرین بکاپ + همه‌ی WALِ آرشیوشده را در پوشه‌ی موقت بازیابی می‌کند، Postgresِ موقت را روی سوکتِ همان پوشه (بی‌پورتِ شبکه،
# archive_mode=off تا چیزی به مخزن ننویسد) بالا می‌آورد، شمارش‌ها را کنارِ دیتابیسِ اصلی چاپ می‌کند و همه را پاک می‌کند.
set -euo pipefail
BIN=/usr/lib/postgresql/16/bin
work=$(mktemp -d /var/lib/postgresql/restore-check.XXXXXX)
trap '"$BIN/pg_ctl" -D "$work/data" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$work"' EXIT
pgbackrest --stanza=zareian --pg1-path="$work/data" --type=default --archive-mode=off restore --log-level-console=warn
cat > "$work/data/postgresql.conf" <<CONF
listen_addresses = ''
port = 5499
unix_socket_directories = '$work'
archive_mode = off
shared_buffers = 128MB
CONF
echo 'local all all peer' > "$work/data/pg_hba.conf"
"$BIN/pg_ctl" -D "$work/data" -l "$work/log" -w -t 600 start >/dev/null
for _ in $(seq 120); do
[ "$(psql -h "$work" -p 5499 -d zareian_mathapp -Atc 'select pg_is_in_recovery()')" = f ] && break; sleep 1
done
Q="select 'users', count(*) from accounts_user union all select 'questions', count(*) from content_question
union all select 'orders', count(*) from payment_order union all select 'payment_events', count(*) from payment_paymentevent
union all select 'login_events', count(*) from accounts_loginevent"
echo "restored | live"
paste -d'|' <(psql -h "$work" -p 5499 -d zareian_mathapp -Atc "$Q") <(psql -p 5440 -d zareian_mathapp -Atc "$Q" | cut -d'|' -f2)

View File

@@ -0,0 +1,25 @@
# تدریسینو: SSH در برابرِ ربات‌های حدسِ رمز، پله‌ای تا آدمِ واقعی (اشتباهِ تایپی، کیبوردِ فارسی) گیر نکند.
# ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه بسته؛ هر بارِ تکرار دو برابر (۲۰، ۴۰، ...، حداکثر یک هفته).
# شبکه‌ی داخلیِ شرکت (ادمین‌ها، ۲۴۷، ۲۴۹) هرگز بسته نمی‌شود.
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 192.168.200.0/24
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
[sshd]
enabled = true
backend = systemd
mode = aggressive
maxretry = 6
findtime = 10m
bantime = 10m
# فقط رباتِ سمج: پنج بار بسته‌شدن در یک روز → یک هفته روی همه‌ی پورت‌ها.
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
banaction = iptables-allports
findtime = 1d
maxretry = 5
bantime = 1w

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Redis for zareian-mathapp only
After=network.target
[Service]
User=redis
Group=redis
ExecStart=/usr/bin/redis-server /etc/zareian-redis.conf
Restart=on-failure
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,5 @@
# تدریسینو، سرور اختصاصی: فقط localhost. max_connections با workers × DB_POOL_MAX_SIZE + 6 در /api/health سنجیده می‌شود.
listen_addresses = '127.0.0.1'
max_connections = 60
shared_buffers = 1GB
effective_cache_size = 4GB

View File

@@ -1,13 +1,21 @@
""" """
اصلاحِ سوال‌های شکل‌دارِ از قبل منتشرشده (seed هیچ‌وقت حذف یا بازنویسی نمی‌کند، پس اصلاح یک ابزارِ جدا می‌خواهد). اصلاحِ محتوای از قبل منتشرشده. seed هیچ‌وقت سوال را حذف یا بازنویسی نمی‌کند و مثال، گام‌ها و شکل‌های درس را
فقط وقتی خالی‌اند پر می‌کند؛ پس اصلاحِ چیزی که روی سرور هست این ابزارِ جدا را می‌خواهد.
python tools/figures/apply_fixups.py fixups.json # فقط فایل‌های seed محلی python tools/figures/apply_fixups.py fixups.json # فقط فایل‌های seed محلی
venv/bin/python tools/figures/apply_fixups.py --db fixups.json # دیتابیسِ همین ماشین (روی سرور، پس از pg_dump) venv/bin/python tools/figures/apply_fixups.py --db fixups.json # دیتابیسِ همین ماشین (روی سرور، پس از pg_dump)
fixups.json: {"delete": [[path, prompt], ...], "blank_title": [[path, prompt], ...]} fixups.json (هر کلید اختیاری است):
delete سوال را کامل پاک می‌کند (پاسخ‌های ثبت‌شده‌اش هم می‌رود؛ فقط برای سوالِ تازه‌منتشرشده) "delete": [[path, prompt], ...] سوال را کامل پاک می‌کند. پاسخ‌های ثبت‌شده‌اش هم پاک می‌شود (CASCADE)،
blank_title عنوانِ زیرِ شکل را خالی می‌کند (عنوان گاهی جوابِ سوال را لو می‌داد) پس فقط برای سوالِ تکراری یا غلط، و پیش از کاربرِ واقعی.
سوال با (path, prompt) شناخته می‌شود؛ هر کدام پیدا نشود فهرست می‌شود، بی‌صدا رد نمی‌شود. "blank_title": [[path, prompt], ...] عنوانِ زیرِ شکل را خالی می‌کند (عنوان گاهی جواب را لو می‌داد).
"lesson": [[path, {"intro"|"example"|"steps"|"figures": مقدار}], ...]
همان فیلدهای داده‌شده‌ی درس را بازنویسی می‌کند.
"question_set": [[path, prompt, {"prompt"|"choices"|"correct"|"figure": مقدار}], ...]
سوال را در جای خودش اصلاح می‌کند. گزینه‌ها به ترتیبِ id عوض می‌شوند و
ردیفشان می‌ماند، چون پاسخ‌های ثبت‌شده به ردیفِ گزینه وصل‌اند؛ تعدادِ
گزینه‌ها باید با سوالِ فعلی یکی باشد.
path برای سوال و درس [پایه، فصل، جزیره، درس] است. هر موردی پیدا نشود یا نخواند فهرست می‌شود، بی‌صدا رد نمی‌شود.
""" """
import json import json
import os import os
@@ -16,64 +24,148 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, ROOT) sys.path.insert(0, ROOT)
LESSON_FIELDS = {'intro': ('introText', 'intro_text'), 'example': ('example', 'example'),
'steps': ('steps', 'steps'), 'figures': ('figures', 'figures')}
QUESTION_FIELDS = {'prompt', 'choices', 'correct', 'figure'}
def key(path, prompt): def key(path, prompt):
return (tuple(path), prompt.strip()) return (tuple(path), prompt.strip())
def _check_fields(fields, allowed, what):
bad = set(fields) - set(allowed)
if bad:
raise SystemExit(f'{what}: فیلدِ ناشناخته {sorted(bad)}')
def apply_seed(fx): def apply_seed(fx):
p = os.path.join(ROOT, 'content', 'seed', 'questions.json') p = os.path.join(ROOT, 'content', 'seed', 'questions.json')
data = json.load(open(p)) data = json.load(open(p))
dele = {key(*x) for x in fx.get('delete', [])} dele = {key(*x) for x in fx.get('delete', [])}
blank = {key(*x) for x in fx.get('blank_title', [])} blank = {key(*x) for x in fx.get('blank_title', [])}
seen_d, seen_b, out = set(), set(), [] qset = {key(path, prompt): f for path, prompt, f in fx.get('question_set', [])}
seen, out, problems = set(), [], []
for q in data['questions']: for q in data['questions']:
k = key(q['path'], q['prompt']) k = key(q['path'], q['prompt'])
if k in dele: if k in dele:
seen_d.add(k) seen.add(('delete', k))
continue continue
if k in blank and q.get('figure'): if k in blank and q.get('figure'):
q['figure']['title'] = '' q['figure']['title'] = ''
seen_b.add(k) seen.add(('blank_title', k))
if k in qset:
f = qset[k]
_check_fields(f, QUESTION_FIELDS, 'question_set')
if 'choices' in f and len(f['choices']) != len(q['choices']):
problems.append(('question_set: تعدادِ گزینه فرق دارد', k))
else:
q.update(f)
seen.add(('question_set', k))
out.append(q) out.append(q)
data['questions'] = out data['questions'] = out
json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1) json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1)
return dele - seen_d, blank - seen_b
cp = os.path.join(ROOT, 'content', 'seed', 'curriculum.json')
cur = json.load(open(cp))
lessons = {}
for g in cur['grades']:
for u in g['units']:
for n in u['nodes']:
for l in n['lessons']:
lessons[(g['title'], u['title'], n['title'], l['title'])] = l
for path, f in fx.get('lesson', []):
_check_fields(f, LESSON_FIELDS, 'lesson')
l = lessons.get(tuple(path))
if l is None:
problems.append(('lesson: پیدا نشد', (tuple(path), '')))
continue
for name, value in f.items():
l[LESSON_FIELDS[name][0]] = value
seen.add(('lesson', tuple(path)))
json.dump(cur, open(cp, 'w'), ensure_ascii=False, indent=1)
return _missing(fx, seen) + problems
def _missing(fx, seen):
miss = []
for op in ('delete', 'blank_title'):
miss += [(f'{op}: پیدا نشد', key(*x)) for x in fx.get(op, []) if (op, key(*x)) not in seen]
miss += [('question_set: پیدا نشد', key(p, pr)) for p, pr, _ in fx.get('question_set', [])
if ('question_set', key(p, pr)) not in seen]
return miss
def apply_db(fx): def apply_db(fx):
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django import django
django.setup() django.setup()
from content.models import Question from django.db import transaction
from content.models import Lesson, Question
lessons = {} lessons = {}
from content.models import Lesson
for l in Lesson.objects.select_related('node__unit__grade'): for l in Lesson.objects.select_related('node__unit__grade'):
lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l.pk lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l
miss_d, miss_b = set(), set() problems = []
def find(path, prompt):
l = lessons.get(tuple(path))
return list(Question.objects.filter(lesson=l, prompt=prompt.strip())) if l else []
with transaction.atomic():
for path, prompt in fx.get('delete', []): for path, prompt in fx.get('delete', []):
lid = lessons.get(tuple(path)) qs = find(path, prompt)
qs = Question.objects.filter(lesson_id=lid, prompt=prompt.strip()) if lid else Question.objects.none()
if not qs.exists():
miss_d.add(key(path, prompt))
else:
qs.delete()
for path, prompt in fx.get('blank_title', []):
lid = lessons.get(tuple(path))
qs = list(Question.objects.filter(lesson_id=lid, prompt=prompt.strip())) if lid else []
if not qs: if not qs:
miss_b.add(key(path, prompt)) problems.append(('delete: پیدا نشد', key(path, prompt)))
for q in qs:
q.delete()
for path, prompt in fx.get('blank_title', []):
qs = find(path, prompt)
if not qs:
problems.append(('blank_title: پیدا نشد', key(path, prompt)))
for q in qs: for q in qs:
if q.figure: if q.figure:
q.figure = {**q.figure, 'title': ''} q.figure = {**q.figure, 'title': ''}
q.save(update_fields=['figure']) q.save(update_fields=['figure'])
return miss_d, miss_b for path, prompt, f in fx.get('question_set', []):
_check_fields(f, QUESTION_FIELDS, 'question_set')
qs = find(path, prompt)
if not qs:
problems.append(('question_set: پیدا نشد', key(path, prompt)))
for q in qs:
choices = list(q.choices.order_by('id'))
if 'choices' in f and len(f['choices']) != len(choices):
problems.append(('question_set: تعدادِ گزینه فرق دارد', key(path, prompt)))
continue
correct = f.get('correct')
for i, c in enumerate(choices):
if 'choices' in f:
c.value = f['choices'][i]
if correct is not None:
c.is_correct = (i == correct)
c.save(update_fields=['value', 'is_correct'])
if 'prompt' in f:
q.prompt = f['prompt']
if 'figure' in f:
q.figure = f['figure']
q.save(update_fields=['prompt', 'figure'])
for path, f in fx.get('lesson', []):
_check_fields(f, LESSON_FIELDS, 'lesson')
l = lessons.get(tuple(path))
if l is None:
problems.append(('lesson: پیدا نشد', (tuple(path), '')))
continue
for name, value in f.items():
setattr(l, LESSON_FIELDS[name][1], value)
l.save(update_fields=[LESSON_FIELDS[n][1] for n in f])
return problems
if __name__ == '__main__': if __name__ == '__main__':
args = [a for a in sys.argv[1:] if a != '--db'] args = [a for a in sys.argv[1:] if a != '--db']
fx = json.load(open(args[0])) fx = json.load(open(args[0]))
miss_d, miss_b = (apply_db if '--db' in sys.argv else apply_seed)(fx) problems = (apply_db if '--db' in sys.argv else apply_seed)(fx)
print(f'deleted/blanked ok; not found: delete={len(miss_d)} blank_title={len(miss_b)}') counts = {k: len(v) for k, v in fx.items()}
for k in list(miss_d)[:5] + list(miss_b)[:5]: print(f'applied {counts}; problems: {len(problems)}')
print(' missing:', k[1][:60]) for what, k in problems[:20]:
print(f' {what}: {" › ".join(k[0])} | {k[1][:60]}')
sys.exit(1 if problems else 0)

File diff suppressed because one or more lines are too long