بازگشت کامنت‌دونی

یادتان باشد روز اول خرداد ۱۴۰۰ غزل خداحافظی نتلیفای را خواندیم. از آن روز کامنت‌دونی تعطیل بود. حالا کامنتدونی با قدرت تمام به صحنه برگشته!

کامنت‌دونی دیروز

با انتقال وبسایت از سرورهای دیگران به سرور خودم بخش نیمه خودکار و نیمه داینامیک وبسایتم یعنی کامنت‌دونی از کار افتاد چرا که پیش از این از سرویس‌های سرویس‌دهنده‌ی قبلی برای ثبت فرم HTML کامنت و بیلد سایت استفاده کرده بودم. حالا این بخش را خودم نوشته‌ام و دوباره راه‌انداخته‌ام که جلوتر شرح می‌دهم.

کامنت‌دونی امروز

برای راه‌اندازی نسخه‌ی جدید یک اسکریپت ساده‌ی CGI با Bash نوشته‌ام و آنرا به کمک وب سرور nginx برای اجرا آماده کرده‌ام. CGI یک روش نسبتا قدیمی اما ساده برای تولید خروجی توسط برنامه‌های موجود روی سرور است.

یک سرور وب وقتی یک درخواست HTTP دریافت می‌کند نگاه می‌کند که آیا آن آدرس یک فایل مثلا index.html است یا اینکه باید توسط یک برنامه تولید بشود. اگر درخواست برای یک فایل باشد، وب سرور آن فایل را از دیسک می‌خواند و به براوزر ما تحویل می‌دهد. براوزر هم آن را نمایش می‌دهد. تمام صفحات این وبسایت فایل‌هایی روی هارددیسک سرور من هستند و مستقیما خوانده شده و به درخواست‌کننده تحویل داده می‌شود.

اما اگر درخواست برای یک فایل نباشد و یک درخواست داینامیک باشد، وب سرور درخواست را یک پراسس دیگر ارجاع می‌کند و پاسخ آن پراسس را در نهایت به براوزر ما تحویل می‌دهد. این وبسایت تنها یک عدد آدرس داینامیک دارد که آنهم به عنوان آدرس فرم HTML کامنتدونی تنظیم شده است. (اگر Ctrl + U را بگیرید می‌توانید در سورس وبسایت فرم را ببینید.)

برای ارتباط بین سرور وب و پراسس‌های دیگر (مثل پایتون یا PHP و مانند اینها) که به درخواست‌های داینامیک رسیدگی می‌کنند پروتکل‌های مختلفی وجود دارد. CGI یکی از این پروتکل‌هاست.

CGI

اما CGI چگونه کار می‌کند؟ برای فهم روش کار باید بدانیم ورودی و خروجی‌های یک پراسس در لینوکس و سیستم‌های یونیکسی چگونه است. وقتی سیستم عامل یک پراسس می‌سازد برای آن سه نوع ورودی و خروجی تعریف می‌کند. برنامه می‌تواند از ورودی بخواند و در خروجی‌ها بنویسد. به ترتیب زیر:

  1. Standard Input - ورودی استاندارد
  2. Standard Output - خروجی استاندارد
  3. Standard Error - خروجی خطا

مثلا وقتی ما در یک ترمینال مشغول کار با شل هستیم (مثلا Bash یا Zsh) و فرامین مختلف را اجرا می‌کنیم، ترمینال می‌تواند در ورودی استاندارد ابزارهای مختلف بنویسید و یا خروجی‌های آنها را بخواند. مثلا دستور ساده‌ی ls که لیستی از یک مسیری در سیستم فایل کامپیوتر به ما نشان می‌دهد چطور کار می‌کند؟ ترمینال فرمان را اجرا می‌کند و ls هم نتیجه را در خروجی استاندارد می‌نویسد و ترمینال این خروجی استاندارد را روی صفحه چاپ می‌کند. اگر خطایی تولید شود ترمینال همین کار را برای خروجی خطا انجام می‌دهد. برای استفاده موثر از ترمینال باید این مفاهیم و نحوه‌ی کنترل آنها را بلد بود. مثلا به کمک اوپراتور کنترلی | می‌توان در شل لوله‌کشی کرد! یعنی استاندارد یا خطای یک دستور را به ورودی بعدی وصل کرد و الی آخر. بحث مفصلی است که از حوصله‌ی این مقاله خارج است.

حالا CGI هم از همین موضوع استفاده می‌کند. یعنی ما سرور را طوری تنظیم می‌کنیم که درخواست‌هایی که به آدرس خاصی ارسال می‌شود را تحویل بدهد به اسکریپ/برنامه‌ی ما و خروجی استاندارد برنامه‌ی ما را به عنوان نتیجه به بازدیدکننده تحویل بدهد.

اگر درخواست HTTP POST باشد، سرور وب پارامترهای فرم ما را یا به عبارتی همان Query String را در ورودی استاندارد برنامه می‌نویسد و برنامه باید آن را از آنها بردارد و پردازش بکند. این بسته به هر زبان فرق می‌کند. اگر درخواست HTTP GET باشد، سرور وب Query String را در یک متغیر محیطی بنام QUERY_STRING می‌نویسد و برنامه می‌تواند آن را بخواند و استفاده کند. سایر اطلاعات نیز در متغیرهای محیطی (Environment Variables) نوشته می‌شوند و برنامه می‌تواند از آنها استفاده کند. مثلا اگر شما برای این مقاله دیدگاهی بنویسید در خروجی آدرس IP خودتان و نیز USER_AGENT ارسالی از مرورگرتان را خواهید دید (جواب معما را در فرم غلط بدهید و فرم را سابمیت کنید خودتان می‌بینید). هر دو اینها از متغیرهای محیطی ارسالی از سرور وب خوانده می‌شوند که آن هم به نوبه‌ی خود از درخواست شبکه‌ای که به دست آن رسیده است استخراج می‌شود.

تنیظم nginx و fcgiwrap

با nginx نمی‌شود مستقیم یک اسکریپت را اجرا کرد بلکه باید از FastCGI استفاده کرد. علت هم اینست که در CGI اولیه سرور وب می‌بایست برای هر درخواست وب یک پراسس بسازد و بعد از اجرا آن را نابود کند. این کار هزینه‌بر است و منابع سرور را هدر می‌دهد هر چند برای سایت‌هایی با ترافیک پایین این موضوع اهمیت چندانی ندارد (مگر اینکه به سایت حمله بشود). با FastCGI تعدادی پراسس در پشت صحنه همیشه گوش به زنگ می‌ایستند و وقتی درخواستی آمد یکی از آنها فورا به آن رسیدگی می‌کند. اما از جایی که من یک اسکرپیت Bash نوشته‌ام خبری از این قرطی‌بازی‌ها نیست! برای اینکه اسکریپت‌هایی مثل اسکرپیت ما را بشود با nginx اجرا کرد برنامه‌نویسی چیزی نوشته بنام fcgiwrap که به nginx امکان اجرای اسکریپت‌های مستقل را می‌دهد.

تنظیم بلاک cgi در nginx روی سرور اوبونتو خیلی ساده است و زیر آمده است:

location /cgi-bin/ {
        gzip off;
        fastcgi_pass  unix:/run/fcgiwrap.socket;
        include /etc/nginx/fastcgi_params;
        fastcgi_param   ALEF_DB "آدرس فایل بانک داده";
        fastcgi_param   ALEF_PUZZLE     "جواب معما";
        fastcgi_param SCRIPT_FILENAME  $document_root$fastcgi_script_name;
}

این بلاک به nginx می‌گوید که درخواست‌هایی که زیر آدرس /cgi_bin/ قرار می‌گیرند را بدهد به سوکت fcgiwrap. دو متغیر محیطی اضافه هم من به آن داده‌ام که یکی جواب معما است و دیگری آدرس بانک داده. بله یک بانک داده هم داریم…

sqlite3

بالاخره داده‌ها را باید جایی نوشت. یا در سرور دیگری یا در سرور خودت. اسکریپت ما برای ثبت دیدگاه‌ها از پایگاه داده‌ی بسیار ساده‌ اما قدرتمند sqlite3 استفاده می‌کند. این پایگاه داده از فرط سادگی نیازی به runtime ندارد. یعنی نیازی نیست که برنامه‌ای در حال اجرا باشد تا بتوان از آن استفاده کرد. تا حجم عظیمی داده را هم می‌تواند در خودش ذخیره کند. مادامی که ما به محدودیت‌هایش احترام بگذاریم، یعنی سعی نکنیم همزمان از چند جای مختلف در پایگاه داده چیزی بنویسیم. البته اسکریپت ما هنوز جلوی اینکار را نمی‌گیرد (انصافا احتمالش خیلی کم است). با اضافه کردن یک Lock مثلا Semaphore یا حتی یک فایل موقتی می‌شود آن مشکل را رفع کرد. می‌گذاریمش برای بعد.

ما یک جدول ساده لازم داریم که می‌سازیم و همزمان به خورد برنامه‌ی sqlite3 می‌دهیم (همه اینها در Makefile هست):

cat <<EOF | sqlite3 "$ALEF_DB"
CREATE TABLE IF NOT EXISTS comments (
-- A unique ID for each comment
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- ISO8601 strings with UTC/zulu timezone, try date --iso-8601=seconds --utc
time TEXT NOT NULL,
-- Comment author's name
name TEXT NOT NULL,
-- Comment author's email
email TEXT NOT NULL,
-- The page where the comment belongs to
page_id TEXT NOT NULL,
-- The ID of a parent comment
reply_to TEXT,
-- Some URL
website TEXT,
-- Whether this is a spam (wrong puzzle answer)
spam INTEGER NOT NULL,
-- The body of the comment
message TEXT NOT NULL
);
EOF

به این روش خوراندن یک متن چند خطی به یک برنامه می‌گویند heredocs. (از طریق ورودی استاندارد! بعله!) همانطور که می‌بینید تعدادی فیلد داریم که برای ثبت یک دیدگاه ضروری است.

اگر به اسکریپت submit هم نظری بیفکنیم می‌بینیم که کارهای زیر را در Bash انجام می‌دهد:

  1. بیت executable ست شده است و فایل یک Shebang هم دارد که می‌گوید که با Bash اجرا بشود
  2. بلافاصله بعد از اجرا HTTP Code و Content-Type را می‌نویسد در خروجی (چون حروف فارسی داریم!)
  3. بلافاصله یک trap ست می‌کند، یعنی این کار را در هر صورت در آخرین لحظه پیش از خروج انجام بده
  4. ورودی استاندارد را می‌خواند و می‌ریزد در POST_STRING (چون فرم ما POST است)
  5. یک تابع داریم که URL Percend-encoding را دیکد می‌کند
  6. پارامترها را از هم باز می‌کنیم (چون با & از هم جدا شده‌اند)
  7. چک می‌کنیم که فیلدهای ضروری وجود داشته باشد اگر ناقص بود درجا خطا و خروج
  8. جواب معما را چک می‌کنیم اگر غلط بود درجا خطا و خروج
  9. اگر متغیر محیطی آدرس فایل پایگاه داده هست کوئری ساخت جدول را ارسال می‌کنیم
  10. یک رکورد در جدول می‌نویسیم
  11. پیام خروج را می‌نویسیم
  12. یک ایمیل هم به وبلاگ‌صاحاب ارسال می‌کنیم (از قبل در سرور آماده است)

این اسکریپت نکات زیادی دارد که هر کدام مقاله‌ای برای خودش می‌طلبد. فقط بگویم که هرچه echo می‌کنیم در خروجی استاندارد نوشته می‌شود و درنهایت به کاربر ارسال می‌شود و آنچه echo شده اما یک >&2 در آخر خط دارد در خروجی خطا نوشته می‌شود و به بازدیدکننده ارسال نمی‌شود (بلکه در لاگ‌های سرور نوشته می‌شود که بنده می‌روم می‌خوانم :)).

این اسکرپیت کاستی زیاد دارد. مثلا چک نمی‌کند که پیام تکراری است یا اسپم را بررسی نمی‌کند (بجز اینکه جواب معما درست باشد) و حتی ممکن است نقطه ضعف امنیتی داشته باشد. هرچند کاربر اجرا کننده‌ی این اسکریپت روی سرور به همین منظور کاربر محدودی است. ولی کار را انجام می‌دهد.

ختم کلام

خلاصه با این مقدمه‌ی طولانی خواستم بگویم که کامنت‌دونی برگشته است و از نظرات شما استقبال می‌کند! من بخش نظرات وبسایت را محلی برای تعامل جمیع خوانندگان و خودم در نظرم می‌گیرم نه محلی برای دیالوگ فقط بین نویسنده و خواننده. بنابراین اگر جوابی برای سوال دیگری داشتید بنویسید و از سوال هم نهراسید!