- ops/pgbackup: SSH tunnel + pg_receivewal (slot zareian_247) + daily verified pg_basebackup (keep 7)
and a restore check, all run on 78.38.35.247 as systemd units
- /api/health reports offsiteBackup (slot active/lag/lost) and warns; OFFSITE_WAL_SLOT setting
- removed statuslog backup_db/backup_verify and their health warnings
- deploy.sh keeps 3 pre-deploy dumps, 3 local patches, drops manual backups after 30 days
- docs: operations backup section, security item 14 closed, CLAUDE.md status
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the server the public URL goes through ArvanCloud and returned 504/000 (2026-10-07), so the
final health step failed even for a healthy service. It now asks the gunicorn socket (path read from
the unit) with our Host header first. DEPLOY_BUNDLE=<file> fetches from a git bundle instead of Gitea,
only when set explicitly; without it an unreachable Gitea still stops the deploy.
Tested: probe_health against a throwaway unix-socket server and a dead URL (bash -n clean).
Not tested: the whole script end to end, until the next deploy.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
(403 node_locked, same rule as /api/home) and a user who has not answered every question
(409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
anyone else finished any island, locked or not, and finishing a locked island's lessons
unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- /api/payment/orders/{id}/pay requires the short-lived signature that paymentUrl carries;
without it (or with another order's) it is the same 404 as an unknown order
- Session.refresh_token stores the SHA-256 of the token; migration accounts.0007 hashes the
existing rows so tokens clients already hold keep working
- /api/leaderboard requires a token (guests have one); names of children were scrapable
- RAG mock only for `manage.py test`, not any command with an argument named "test"
- tools/loadtest: user factory, load generator, fake GapGPT+Qdrant, with method notes
- security-before-production.md lists only open items; closed ones become a guards table
with the tests that hold them. Comments describe current behaviour, not history
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- gunicorn gthread 8 -> 32 threads per worker: each assistant stream holds a thread for
the whole answer; with 3x8, about 20 concurrent asks already queued ordinary page loads
for seconds (up to 23 s at 50 asks locally, 7 s at 20 asks on the live server). With 3x32,
75 concurrent asks kept page loads under 110 ms (laptop, simulated model and Qdrant)
- deploy.sh: new step closes the project dir to other accounts and sets the database and
backups to 0600/0700 on every release; user `nobody` on the shared server could read
db.sqlite3 and every backup
- requirements: python-dotenv 1.2.2, urllib3>=2.8.0 (known CVEs in the installed versions),
gunicorn pinned to the server's version (was only installed by hand)
- security-before-production.md: items 19-23 (file permissions, DEBUG=1 still in the systemd
unit, dependencies, frontend headers, guest AI cap per shared IP) and the reproduced
X-Forwarded-For spoof through the origin IP
- model-prices.md: 0.183 cents per answer from 50 answers in the live load test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- seed_content no longer undoes admin work: a SeedRecord ledger (content/seed_sync.py)
stops deleted/renamed/re-worded content from being recreated and keeps admin-edited
lesson text; new rows in the seed files still arrive (migration content.0010)
- plan list cache is invalidated on Plan save (receiver used a stale 'v1' key)
- token refresh per-IP cap 30/hour -> 600/hour (TOKEN_REFRESH_IP_RATE)
- a failed gateway result only changes a pending order (forged NOK no longer
overwrites refunded/canceled)
- tutor gets the correct answer only after the student has answered
- support reply notification moved to a TicketMessage post_save signal
- global daily cap on paid AI questions (AI_GLOBAL_DAILY_MAX, default 3000)
- Bazaar verify uses only the order's SKU; bazaar orders require a SKU
- /api/health warns on the committed signing key; deploy.sh saves local commits;
400 for bad ?unit= / ?period=; account delete keeps the referrer's Conversion
- earlier today: no stub answers outside DEBUG, ?grade= removed, logged fallbacks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A domain change is coming (the Zarinpal terminal rejects the current callback
domain). ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, CORS defaults, the Bazaar
redirect URI and deploy.sh's public health URL each spelled the domain out.
They now derive from PUBLIC_BASE_URL and FRONTEND_URL, which keep today's
domain as their default; env values for the three lists are still added on
top, so the old domain can stay allowed while DNS moves.
ZARINPAL_CALLBACK_URL used os.environ.get(key, default), so an empty key in
.env produced an empty callback and every payment would fail closed. It now
uses the same `.strip() or` fallback as its neighbours; the new test caught it.
config/tests_domains.py loads settings in a subprocess with a new domain and
fails if any derived value still mentions the old one. docs/operations.md
gains the step-by-step domain move (DNS, .env, Zarinpal/Bazaar panels,
frontend config.js, verification). CLAUDE.md records that the frontend is
now ours and where the rebuild stands. 624 tests green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three gaps found while seeding test data for the frontend, all of the same
kind: a contract that looks complete from the outside and is empty underneath.
Nothing in the codebase ever wrote a LeaderboardEntry. Not a signal, not a
service — only the admin form and seed_test_data. So /api/leaderboard returned
`entries: []` forever on any real server, and any row put there was a frozen
number that never tracked activity. rebuild_leaderboard now derives both
periods from real XP: all_time from UserStats.xp, weekly from a sum over
content.UserLessonCompletion in a window. It deliberately does not count
UserAnswer.xp_earned — that field is written but never added to UserStats, so
summing both would report roughly double. deploy.sh runs it every release;
a cron is the real answer once traffic is real, and the script says so.
'lesson_completed' was declared in Notification.TYPE_CHOICES but nothing
called notify() with it, so finishing a lesson produced no notification at
all. LessonCompleteView now emits one, on first completion only (the same
condition that grants xp/coins), inside the completion transaction so a lost
notification cannot leave a silent half-done state.
The notification list serializer returned only id/type/title/isRead. body and
created_at were on the model from the start but never exposed, so the frontend
had neither the text of a notification nor its time. Both added; the change is
purely additive, so the existing client keeps working.
472 tests green, up from 458. makemigrations --check, check and spectacular
are all clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
تا حالا اگر کسی روی سرور فایلی را دستی عوض کرده بود، `git pull --ff-only`
شکست میخورد و کلِ اسکریپت همانجا میایستاد. چون هیچکدام از مراحلِ بعدی
اجرا نمیشد، سرور با کدِ قدیمی میماند — بیصدا. یک بار همین دو روز طول کشید.
حالا مرحلهی اول fetch میکند، اگر تغییرِ دستی (یا کامیتِ محلی) باشد آن را در
.deploy-backups/*.patch نگه میدارد، و با `git reset --hard origin/<branch>`
کنارش میگذارد و ادامه میدهد. سرور در هر وضعیتی باشد، یک اجرا دقیقاً همان
چیزی میشود که روی gitea است.
`git clean` عمداً زده نمیشود و نباید اضافه شود: db.sqlite3 و .env و
.django_secret_key ردیابینشده و داخلِ همین پوشهاند.
با مخزنِ ساختگی هر سه حالت آزموده شد: تغییرِ دستی، درختِ تمیز، و کامیتِ محلیِ
واگرا. در هر سه، .env و db.sqlite3 دستنخورده ماندند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bash اسکریپت را یکجا نمیخواند؛ حین اجرا از روی دیسک ادامه میدهد. پس وقتی
`git pull` همین فایل را عوض میکند، ادامهی اجرا مخلوطی از نسخهی قدیم و جدید
است. یک بار همین اتفاق افتاد و گران تمام شد: نسخهی قدیمی که در حال اجرا بود
سرویسِ تیمِ دیگری را ریاستارت کرد، در حالی که نسخهی اصلاحشده همان لحظه روی
دیسک نشسته بود.
حالا امضای فایل قبل و بعدِ pull مقایسه میشود و اگر عوض شده باشد، نسخهی تازه
از اول اجرا میشود (با SKIP_PULL=1 و یک نگهبان در برابرِ حلقهی بیپایان).
و یک حفرهی کوچکترِ همخانواده: اگر زمانِ بالا آمدنِ سرویس اصلاً خوانده نشود،
دیگر موفقیت فرض نمیشود. «نمیدانم» همان چیزی بود که دو روز پنهانش کرد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
روی این سرور یک `gunicorn.service` هست که مالِ تیمِ دیگری است. هر کسی که
عجله داشته باشد و `systemctl restart gunicorn` بزند، سرویسِ آنها را
میخواباند و سرویسِ ما اصلاً ریاستارت نمیشود — یعنی دیپلوی بیصدا اعمال
نمیشود. همین دیشب یک بار اتفاق افتاد.
حالا همان یونیت با نامِ `zareian-mathapp` هم صدا زده میشود (symlink در
/etc/systemd/system). نامِ قبلی هم کار میکند، پس چیزی نمیشکند.
خط تیره و نه نقطه: systemd آخرین نقطه را جداکنندهی نوعِ یونیت میداند، پس
`zareian.mathapp` را باید هر بار کامل با `.service` نوشت.
deploy.sh هم اول دنبالِ همین نام میگردد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سرور دو روز کدِ قدیمی را سرو کرد و هیچکس نفهمید. علتش این بود که
`systemctl restart` شکست خورده بود ولی پروسهی قبلی هنوز بالا بود و همان
۲۰۰ را میداد — پس اسکریپت «✓ سرویس سالم بالا آمد» چاپ کرد و با کدِ صفر
تمام شد. محتوا و فایلهای استاتیک هم روی دیسک بودند و سرو نمیشدند.
حالا زمانِ بالا آمدنِ سرویس قبل و بعد مقایسه میشود. اگر عوض نشده باشد،
۲۰۰ گرفتن دیگر موفقیت حساب نمیشود و اسکریپت با خطا تمام میکند.
نامِ عامِ `gunicorn` هم از فهرستِ حدسِ سرویس برداشته شد: روی سرورِ واقعی یک
`gunicorn.service` هست که مالِ تیمِ دیگری است، و حدس زدنش یعنی ریاستارت
کردنِ سرویسِ یک نفرِ دیگر. نامِ درستِ ما `gunicorn-mathapp` است.
`api/health/` هم کنارِ `api/health` ثبت شد. پایشِ سرور شکلِ اسلشدار را صدا
میزد و چون ثبت نبود، کسی روی سرور `config/urls.py` را دستی ویرایش کرده
بود — و از آن به بعد هر `git pull` روی همان فایل گیر میکرد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
پنلِ ادمین روی سرور بدونِ CSS بالا آمد و هیچ خطایی هم در لاگ نبود.
علت ترتیبِ کارها بود: این مرحله وقتی DEBUG روشن بود رد میشد، چون در آن حالت
whitenoise از finders میخواند و جمعآوری لازم نیست. کسی با DEBUG روشن دیپلوی
کرد و بعد DEBUG را خاموش کرد — کارِ درستی هم بود — و از آن لحظه جنگو دنبالِ
فایلهایی میگشت که هیچوقت جمع نشده بودند.
یعنی درستیِ دیپلوی به ترتیبِ کارِ یک آدم گره خورده بود. چند ثانیه صرفهجویی،
به قیمتِ شکستی که فقط در مرورگر دیده میشود. حالا بیقید و شرط اجرا میشود؛
idempotent است و در هر دو حالت درست کار میکند.
staticfiles/ هم به gitignore اضافه شد: حالا که این مرحله همیشه اجرا میشود،
روی ماشینِ هر توسعهدهندهای ساخته میشود و ۴٫۴ مگابایت فایلِ تولیدی نباید
با یک `git add .` واردِ مخزن شود.
بررسی شد که با DEBUG=False کلِ مسیر کار میکند: استاتیک ۲۰۰، صفحهی ادمین
۲۰۰، API ۲۰۰، و ورود با OTP از پنل. کدِ 0000 همانطور که باید، رد میشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سرورِ production از روزِ اول `{"grades": []}` برگردانده: دیتابیس gitignore است
(درست هم هست) و تنها راهِ پر کردنش اجرای `rag_import_collection` و بعد
`seed_curriculum_from_textbook` روی همان سرور بود — یعنی وابستگی به Qdrant، به
اعتبارِ AI که تمام شده، و به کسی که SSH دارد. نتیجه این شد که هیچکس نتوانست
چیزی را تست کند.
حالا برنامهی درسی در `content/seed/curriculum.json` کامیت میشود (۴ پایه، ۳۱
فصل، ۱۶۸ مبحث، ۱۳۰۰ درس، ۵۲۷ کیلوبایت متن) و `deploy.sh` خودش بالا میآوردش.
سرورِ تازه نه Qdrant میخواهد نه ترمینال.
`db.sqlite3` عمداً کامیت نمیشود: باینری است، دیف نمیشود، و هر دیپلوی محتوای
سرور — و فردا پاسخهای کاربران — را با نسخهی لوکالِ یک نفر جایگزین میکند.
سوالها با مسیرِ عنوان ذخیره میشوند نه با lessonId. این تصمیمِ اصلیِ کامیت
است: لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ دیتای
دمو)، و همان کوریکولوم روی دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ میگیرد. با id،
سوالِ درسِ ۳ روی سرور به درسِ بیربطی میچسبید — بدونِ هیچ خطایی. تست
`test_question_follows_the_title_path_not_the_id` همین را نگه میدارد.
`seed_content` ایدمپوتنت است و هیچچیز پاک نمیکند: کوریکولوم با عنوان
همسانسازی میشود و سوالِ تکراری رد میشود (`skip_existing`). روی سروری که
کاربرِ واقعی دارد، دیپلوی نباید بتواند محتوا یا پاسخی را از بین ببرد — و
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بیآنکه دیپلویِ بعدی برش گرداند.
ردیفهای `[DEMO]`/`[SEED]` موقعِ export کنار گذاشته میشوند؛ آنها همانهاییاند
که `purge_demo_data` برای پاک کردنشان نوشته شد و جایشان در فایلی که مستقیم روی
production لود میشود نیست.
هنوز صفر سوالِ واقعی داریم: هر ۱۴ سوالِ لوکال رویِ ردیفهای دمو بودند. مسیرِ
سوالها ساخته و تست شده، ولی تا وقتی اعتبارِ AI شارژ نشود فایلش خالی میماند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
دو حالتی که فقط روی سرورِ واقعی پیش میآیند، نه اینجا.
اسکریپتهای قدیمیِ activate به PS1 بدونِ محافظ دست میزنند و زیرِ set -u با
«unbound variable» کلِ دیپلوی را میخوابانند — venv اینجا سالم است ولی venv
سرور لزوماً نه، پس set -u دورِ همان یک خط موقتاً خاموش میشود.
بررسیِ سلامت فقط 127.0.0.1:8000 را میزد. اگر gunicorn روی سوکت یا پورتِ
دیگری باشد، دیپلویِ کاملاً موفق هم با پیامِ خطا تمام میشد. حالا آدرسِ
عمومی هم امتحان میشود و در پایان میگوید کدام آدرسها را زده.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
بازبینیِ کارِ امروز، سه مورد پیدا کرد.
/api/health وقتی مهاجرتِ اعمالنشده وجود داشت میشکست: کدِ فهرستکردنِ
مهاجرتها به migration.key تکیه میکرد که اصلاً وجود ندارد (فیلدها app_label
و name هستند). یعنی دقیقاً در همان حالتی که این بررسی برایش ساخته شده،
بهجای نامِ مهاجرتها فقط AttributeError برمیگرداند. حالا تستی دارد که
مهاجرتِ معلق را شبیهسازی میکند — چیزی که نبودش باعث شد اولین بار غلط بماند.
وارد کردنِ سوال با replace عددِ غلط گزارش میکرد: delete() در جنگو مجموعِ
همهی ردیفهای آبشاری را میدهد، پس «۵ سوال پاک شد» در واقع گزینهها و
پاسخها را هم میشمرد.
deploy.sh بررسیِ متغیرهای محیطیاش با تنظیماتِ جدید ناسازگار شده بود:
متغیرهایی را اجباری میدانست که حالا پیشفرض دارند، و فقط شل را میخواند نه
.env — یعنی روی سرورِ درست هم دیپلوی را رد میکرد. حالا بهجای تکرارِ منطقِ
تنظیمات، از خودِ جنگو میپرسد چه چیزی مؤثر است و گزارش میدهد.
کوچکتر: بررسیِ سلامت یک درخواست میزند نه دو تا، و شکستِ ریاستارت دیگر
اسکریپت را بیصدا تمام نمیکند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
دو فایلِ نمونه یعنی دو جا که باید همزمان بهروز بمانند، و همان چیزی بود که
قبلاً باعث شد نمونه عقب بماند و کسی که سرور را بالا میآورد نداند چه بنویسد.
حالا یک فایل، هم برای ماشینِ محلی هم برای سرور.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
سه حالتِ خراب که هیچکدام در لاگِ سرور ردی نمیگذارند و فقط وقتی کشف میشدند
که یک آدم از بیرون اپ را دستی امتحان کند.
CORS_ALLOWED_ORIGINS فقط از env خوانده میشد و با DEBUG روشن به localhost
محدود میماند. یعنی بکاندِ کاملاً سالم، ولی مرورگر همهی درخواستهای
math.tadrisino2.ir را بلاک میکند — خطایی که سمتِ سرور دیده نمیشود و فقط در
کنسولِ مرورگر پیداست. origin های شناختهشده حالا پیشفرضِ داخلِ کدند تا یک
متغیرِ فراموششده اتصال را نشکند؛ env همچنان بازنویسیشان میکند.
/api/health حالا warnings هم میدهد. مهمترینش: با DEBUG روشن روی سرورِ عمومی،
DEV_MASTER_OTP یعنی هر کسی با کدِ 0000 میتواند بهجای هر شمارهای وارد شود.
این تا امروز فقط با خواندنِ تنظیماتِ سرور معلوم میشد، و ما به سرور دسترسی
نداریم. allowedOrigins هم برگردانده میشود تا مشکلِ CORS از بیرون قابلِ
تشخیص باشد.
deploy.sh قبل از دست زدن به دیتابیس متغیرهای لازم را بررسی میکند و با پیامِ
روشن میایستد، بهجای اینکه وسطِ کار بشکند و 502 بدهد.
.env.production.example همانها را یکجا مستند میکند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
بالا آوردنِ نسخهی جدید تا الان چند کارِ دستی به ترتیبِ درست بود (pull، نصبِ
وابستگیها، مهاجرت، استاتیک، ریاستارت) و فراموش شدنِ هر کدام اپ را میخواباند
با خطایی که از بیرون فقط «502 Bad Gateway» دیده میشد. همین الان هم سرور
دقیقاً به همین دلیل خوابیده: کامیتِ ac1b1af پکیجِ django-cors-headers را اضافه
کرد، و بدونِ pip install جنگو با ModuleNotFoundError بالا نمیآید.
deploy.sh همه را به ترتیب و idempotent انجام میدهد. pip install همیشه اجرا
میشود، نه فقط وقتی requirements عوض شده — تشخیصِ «عوض شده یا نه» خودش یک جای
فراموششدنیِ دیگر است. آخرِ کار خودش سلامت را بررسی میکند و اگر بالا نیامده
باشد علتهای رایج را چاپ میکند، بهجای اینکه کسی بعداً از بیرون کشفش کند.
GET /api/health وضعیت را بدونِ احراز و بدونِ SSH بیرون میدهد: دیتابیس،
مهاجرتهای اعمالنشده، کش، و شمارشِ پایه/درس/سوال روی همان سرور. مهمترینش
مهاجرتِ اعمالنشده است — حالتی که اپ ظاهراً بالا میآید و بعد زیرِ بارِ واقعی
با خطای ستونِ ناموجود میشکند. عمداً هیچ سکرتی برنمیگرداند، فقط بله/خیرِ
«تنظیم شده».
۵۰۳ برمیگرداند وقتی دیتابیس/کش/مهاجرت خراب است تا پایشِ خودکار بتواند رویش
حساب کند؛ نبودِ محتوا خرابی حساب نمیشود ولی گزارش میشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>