EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Web / ทำแอปคุมเพจผ่าน Graph API สามปลายทางที่ลืมแล้วไม่ผ่านรีวิว
Web

ทำแอปคุมเพจผ่าน Graph API สามปลายทางที่ลืมแล้วไม่ผ่านรีวิว

โดย Ton-Or ·22 ต.ค. 2026·อ่าน 10 นาที
ทำแอปคุมเพจผ่าน Graph API สามปลายทางที่ลืมแล้วไม่ผ่านรีวิว

คนส่วนใหญ่ตกรีวิวไม่ใช่เพราะโค้ดหลักไม่ดี แต่เพราะลืมปลายทางสามตัวที่ไม่ได้ทำงานอะไรเลยในระบบของเรา แต่แพลตฟอร์มถือว่าเป็นสัญญาที่ให้ไว้กับผู้ใช้

สรุปใน 1 นาที

อาการ/ปัญหา
ระบบหลังบ้านที่อ่านและตอบข้อความเพจ Facebook ตกรีวิวแอป เพราะลืมปลายทางที่บังคับ
ใครควรอ่าน
นักพัฒนาที่ต้องเชื่อมระบบกับเพจ Facebook
ทางแก้แบบสั้น
ทำปลายทางที่บังคับสามตัวให้ครบ ตรวจลายเซ็นคำขอให้ถูกวิธี และแลกโทเคนสั้นเป็นโทเคนเพจเก็บนอกรากเว็บ
ข้ามไปขั้นตอน

พอดีผมมีงานที่ต้องให้ระบบหลังบ้านอ่านข้อความที่เข้ามาทางเพจ แล้วตอบกลับอัตโนมัติในบางกรณี กับโพสต์ประกาศตามรอบเวลาได้เอง โจทย์แบบนี้มีทางทำที่ถูกต้องอยู่ทางเดียว คือสมัครทำแอปกับแพลตฟอร์ม ขอสิทธิ์ที่ต้องใช้ แล้วส่งเข้ารีวิวให้ผ่านก่อน

ผมเขียนตัวหลักเสร็จภายในวันเดียว แต่ไปตกรีวิวรอบแรกด้วยเรื่องที่ไม่ได้เกี่ยวกับตัวหลักเลย คือปลายทางบังคับสามตัวที่ผมไม่ได้ทำ เพราะตอนอ่านเอกสารครั้งแรกผมนึกว่ามันเป็นของเสริมสำหรับแอปที่เก็บข้อมูลผู้ใช้จริงจัง ซึ่งเข้าใจผิดเต็มๆ

รหัสแอป ค่าลับ โทเคน ชื่อเพจ และโดเมนทั้งหมดในบทความนี้ผมสมมติขึ้นใหม่ทั้งชุด ส่วนโครงสร้างโค้ด ชื่อฟิลด์ และลำดับการทำงาน เป็นของจริงจากงานนั้น

สองเส้นทางที่ต้องเลือกก่อนเขียนโค้ดบรรทัดแรก

ก่อนจะลงมือ ผมแนะนำให้ตัดสินใจเรื่องนี้ก่อน เพราะมันเปลี่ยนงานทั้งก้อน

ทางที่หนึ่ง

ใช้บริการของผู้ให้บริการที่ผ่านรีวิวแล้ว

  • ได้ใช้งานภายในวันเดียว ไม่ต้องรอรีวิว
  • ไม่ต้องดูแลปลายทางบังคับใดๆ เอง
  • ข้อความของลูกค้าวิ่งผ่านมือที่สาม
  • มีค่าใช้จ่ายรายเดือน และถูกจำกัดว่าทำอะไรได้บ้าง
ทางที่สอง

ทำแอปเอง แล้วส่งเข้ารีวิว

  • ข้อมูลอยู่ในระบบเราทั้งหมด ต่ออะไรก็ได้
  • ไม่มีค่าบริการรายเดือน
  • ต้องมีโดเมนที่เปิดให้เข้าถึงได้จากภายนอก และต้องมีปลายทางบังคับครบ
  • รอบรีวิวกินเวลาเป็นวันถึงสัปดาห์ และตกได้

ส่วนตัวผมเลือกทางที่สอง เพราะงานนี้ต้องต่อเข้าระบบหลังบ้านที่มีอยู่แล้ว และไม่อยากให้ข้อความของลูกค้าวิ่งผ่านที่อื่น แต่ถ้าใครทำแค่ตอบอัตโนมัติง่ายๆ ทางที่หนึ่งคุ้มกว่าเยอะ ไม่ต้องฝืน

ทางที่ถูกต้องมีทางเดียว คือผ่าน Graph API ที่แพลตฟอร์มให้มา

ทุกอย่างในบทความนี้เป็นการเรียกใช้ส่วนต่อประสานที่แพลตฟอร์มเปิดให้อย่างเป็นทางการ ด้วยโทเคนที่ได้มาจากการที่เจ้าของเพจกดอนุญาตเอง วิธีอื่นที่พยายามเข้าถึงข้อมูลโดยไม่ผ่านช่องทางนี้ ผิดข้อตกลงการใช้งาน และเสี่ยงที่บัญชีของลูกค้าจะโดนระงับ ไม่ใช่แค่แอปของเราเท่านั้น ถ้าจะทำงานให้ลูกค้า ต้องไม่พาลูกค้าไปเสี่ยงแบบนั้น

สามปลายทางที่ลืมแล้วตกรีวิว

เมื่อแอปขอสิทธิ์เข้าถึงข้อมูลของผู้ใช้ แพลตฟอร์มถือว่าเราให้สัญญาสามข้อกับผู้ใช้ และสัญญาสามข้อนั้นต้องมีปลายทางจริงบนโดเมนของเรารองรับ

ปลายทางบังคับdata-deletionรับคำขอลบข้อมูล->deletion-statusหน้าตรวจสถานะคำขอ->deauthorizeผู้ใช้ถอนสิทธิ์
สามตัวนี้ต้องทำงานได้จริงตอนที่คนรีวิวกดทดสอบ ไม่ใช่แค่มีหน้าอยู่

ตัวแรกต้องรับคำขอแบบ POST ที่มีลายเซ็นกำกับ แล้วตอบกลับเป็น JSON ที่มีสองคีย์คือที่อยู่หน้าสถานะกับรหัสยืนยัน ตัวที่สองคือหน้าที่เปิดด้วยรหัสยืนยันนั้นแล้วต้องแสดงสถานะได้ ตัวที่สามต้องรับคำขอตอนผู้ใช้ถอนสิทธิ์แล้วตอบ 200 กลับไป ถ้าตัวใดตัวหนึ่งตอบผิดรูปแบบ รีวิวตกทั้งชุด และข้อความที่ได้กลับมามักบอกแค่ว่าไม่ผ่าน ไม่ได้บอกว่าตัวไหน

ผมเขียนทั้งสามตัวเป็นไฟล์ PHP ธรรมดาไฟล์ละไม่ถึงสามสิบบรรทัด ใช้เวลารวมไม่ถึงชั่วโมง เทียบกับการรอรีวิวรอบใหม่อีกหลายวันแล้วถือว่าถูกมาก

ตรวจลายเซ็นของคำขอที่ส่งเข้ามา อย่าเชื่อเนื้อความเปล่าๆ

คำขอสองตัวแรกมาพร้อมพารามิเตอร์ชื่อ signed_request ซึ่งเป็นข้อความสองท่อนคั่นด้วยจุด ท่อนหน้าคือลายเซ็น ท่อนหลังคือเนื้อความ ทั้งสองท่อนเข้ารหัสแบบฐานหกสิบสี่ชนิดที่ปลอดภัยกับที่อยู่เว็บ ซึ่งใช้อักขระขีดกลางกับขีดล่างแทนอักขระสองตัวของฐานหกสิบสี่ปกติ

ถ้าเราถอดรหัสท่อนหลังแล้วเชื่อเลยโดยไม่ตรวจลายเซ็น ใครก็ปลอมคำขอมาสั่งลบข้อมูลของคนอื่นได้

<?php
// ตรวจและถอด signed_request — คืน null ถ้าลายเซ็นไม่ตรง
function fb_parse_signed_request($signed_request, $app_secret) {
    if (!$signed_request || strpos($signed_request, '.') === false) return null;
    list($encoded_sig, $payload) = explode('.', $signed_request, 2);

    // base64url -> base64 ปกติ ก่อนถอด
    $sig  = base64_decode(strtr($encoded_sig, '-_', '+/'));
    $data = json_decode(base64_decode(strtr($payload, '-_', '+/')), true);
    if (!is_array($data)) return null;

    // เซ็นจาก "ท่อนหลังดิบ" ไม่ใช่จากข้อมูลที่ถอดแล้ว
    $expected = hash_hmac('sha256', $payload, $app_secret, true);

    // เทียบแบบเวลาคงที่ ป้องกันการเดาทีละไบต์จากเวลาที่ใช้ตอบ
    if (!hash_equals($expected, $sig)) return null;

    return $data;
}
สองจุดที่คนเขียนผิดกันบ่อยในฟังก์ชันสิบบรรทัดนี้

จุดแรกคือเอาข้อมูลที่ถอดรหัสแล้วไปคำนวณลายเซ็นซ้ำ ซึ่งจะไม่มีทางตรง เพราะลายเซ็นเซ็นจากข้อความดิบท่อนหลังก่อนถอด จุดที่สองคือเทียบลายเซ็นด้วยเครื่องหมายเท่ากับสามตัวธรรมดา ซึ่งหยุดเทียบทันทีที่เจอไบต์แรกที่ต่าง ทำให้เวลาที่ใช้ตอบบอกใบ้ได้ว่าเดาถูกไปกี่ไบต์ ต้องใช้ฟังก์ชันเทียบแบบเวลาคงที่เสมอ

ปลายทางรับคำขอลบข้อมูล ต้องตอบสองคีย์

ตัวนี้คือตัวที่คนตกกันมากที่สุด เพราะมันไม่ได้ตอบหน้าเว็บ แต่ต้องตอบ JSON ที่มีโครงตายตัว

<?php
// data-deletion.php — แพลตฟอร์มยิง POST มาเมื่อผู้ใช้กดขอลบข้อมูล
$cfg = require __DIR__ . '/fb_config.php';
require __DIR__ . '/_fbhelp.php';

$data = fb_parse_signed_request($_POST['signed_request'] ?? '', $cfg['app_secret']);
if (!$data || empty($data['user_id'])) {
    http_response_code(400);
    header('Content-Type: application/json');
    echo json_encode(['error' => 'invalid signed_request']);
    exit;
}

$uid  = preg_replace('/\D/', '', $data['user_id']);   // เหลือแต่ตัวเลข
$code = bin2hex(random_bytes(8));                     // รหัสยืนยัน 16 ตัวอักษร

@mkdir(__DIR__ . '/_deletions', 0755, true);
@file_put_contents(__DIR__ . '/_deletions/' . $code . '.json', json_encode([
    'user_id'      => $uid,
    'requested_at' => date('c'),
    'status'       => 'received',
]));

// ต่อจากนี้คือหน้าที่ของระบบหลังบ้าน: ลบข้อมูลของ $uid ออกจากฐานข้อมูลจริง

header('Content-Type: application/json');
echo json_encode([
    'url'               => 'https://example.com/deletion-status.php?code=' . $code,
    'confirmation_code' => $code,
]);

สิ่งที่ตอบกลับไปต้องหน้าตาแบบนี้เป๊ะ ชื่อคีย์ผิดตัวเดียวคือตก

{
  "url": "https://example.com/deletion-status.php?code=9f3c1a7b2e5d4c86",
  "confirmation_code": "9f3c1a7b2e5d4c86"
}

หน้าสถานะที่อยู่ปลายลิงก์นั้นไม่ต้องสวย แต่ต้องเปิดได้จริงด้วยรหัสที่ส่งไป และต้องบอกสถานะเป็นภาษาคนอ่าน ผมกรองรหัสด้วยนิพจน์ปกติให้เหลือแต่อักขระฐานสิบหกก่อนเอาไปประกอบเป็นชื่อไฟล์เสมอ เพราะค่านั้นมาจากที่อยู่เว็บ

<?php
// deletion-status.php — หน้าที่ผู้ใช้เปิดจากลิงก์ที่เราส่งกลับไป
header('Content-Type: text/html; charset=utf-8');

// กรองให้เหลือแต่ฐานสิบหก กันการไต่โฟลเดอร์ผ่านชื่อไฟล์
$code = preg_replace('/[^a-f0-9]/', '', $_GET['code'] ?? '');
$f = __DIR__ . '/_deletions/' . $code . '.json';

echo '<meta charset=utf-8><title>สถานะคำขอลบข้อมูล</title>';
if ($code !== '' && is_file($f)) {
    echo '<h2>คำขอลบข้อมูล</h2>'
       . '<p>รหัสยืนยัน: <b>' . htmlspecialchars($code) . '</b></p>'
       . '<p>สถานะ: ได้รับคำขอแล้ว ระบบจะลบข้อมูลที่เกี่ยวข้องกับบัญชีของคุณ</p>'
       . '<p>สอบถามเพิ่มเติม [email protected]</p>';
} else {
    echo '<h2>ไม่พบคำขอ</h2><p>รหัสไม่ถูกต้องหรือหมดอายุ · [email protected]</p>';
}

ตัวที่สามสั้นที่สุด คือรับคำขอตอนผู้ใช้ถอนสิทธิ์แอป หน้าที่ของเราคือบันทึกไว้ ล้างโทเคนของผู้ใช้คนนั้นออกจากระบบ แล้วตอบ 200

<?php
// deauthorize.php — ผู้ใช้ถอดแอปออกจากบัญชีของตัวเอง
$cfg = require __DIR__ . '/fb_config.php';
require __DIR__ . '/_fbhelp.php';

$data = fb_parse_signed_request($_POST['signed_request'] ?? '', $cfg['app_secret']);
if ($data && !empty($data['user_id'])) {
    @file_put_contents(__DIR__ . '/_deauth.log',
        date('c') . ' user_id=' . preg_replace('/\D/', '', $data['user_id']) . "\n",
        FILE_APPEND);
    // ต่อจากนี้: ลบโทเคนและสิทธิ์ของผู้ใช้คนนี้ออกจากระบบจริง
}
http_response_code(200);
echo 'ok';

ก่อนส่งเข้ารีวิว ผมทดสอบทั้งสามตัวด้วยคำสั่งบรรทัดเดียว ให้แน่ใจว่ารหัสสถานะที่ได้เป็นไปตามที่ควรเป็น

# ยิงคำขอลบข้อมูลแบบไม่มีลายเซ็น ต้องได้ 400 ไม่ใช่ 500 และไม่ใช่ 200
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://example.com/data-deletion.php

# หน้าสถานะด้วยรหัสมั่ว ต้องได้ 200 พร้อมข้อความว่าไม่พบคำขอ ไม่ใช่หน้าขาว
curl -s https://example.com/deletion-status.php?code=zzzz | head -c 200

# ถอนสิทธิ์แบบไม่มีลายเซ็น ต้องได้ 200 เปล่าๆ ไม่ใช่ 500
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://example.com/deauthorize.php
3ปลายทางบังคับ
400รหัสที่ตัวรับลบข้อมูลต้องตอบเมื่อลายเซ็นผิด
200รหัสที่ตัวถอนสิทธิ์ต้องตอบเสมอ
16ตัวอักษรของรหัสยืนยัน
60 วันอายุโทเคนผู้ใช้แบบยาว

แลกโทเคนสั้นเป็นโทเคนเพจ แบบทำครั้งเดียวจบ

ขั้นตอนนี้เป็นจุดที่คนงงกันมากที่สุด เพราะโทเคนมีหลายชนิดและอายุไม่เท่ากัน ลำดับที่ถูกต้องมีสามขั้น

  1. เจ้าของเพจกดอนุญาตแอป แล้วเราได้ โทเคนผู้ใช้แบบสั้น มา อายุประมาณสองชั่วโมง ใช้ทำงานจริงไม่ได้
  2. เอาโทเคนสั้นไปแลกเป็น โทเคนผู้ใช้แบบยาว ที่ปลายทาง oauth/access_token โดยส่งรหัสแอปและค่าลับแอปไปด้วย ได้อายุประมาณหกสิบวัน
  3. เอาโทเคนผู้ใช้แบบยาวไปเรียก me/accounts ซึ่งคืนรายการเพจที่ผู้ใช้คนนั้นเป็นผู้ดูแล พร้อม โทเคนเพจ ของแต่ละเพจมาให้ โทเคนเพจที่ออกจากโทเคนผู้ใช้แบบยาวคือตัวที่ไม่มีวันหมดอายุ

ถ้าข้ามขั้นที่สอง แล้วเอาโทเคนสั้นไปเรียก me/accounts เลย โทเคนเพจที่ได้จะหมดอายุตามโทเคนสั้น ซึ่งแปลว่าระบบจะพังในอีกสองชั่วโมงข้างหน้าโดยที่เราไม่รู้ตัว นี่คือสาเหตุอันดับหนึ่งของอาการที่ว่าเมื่อวานยังใช้ได้อยู่เลย

ลำดับแลกโทเคน ต้องผ่านโทเคนผู้ใช้แบบยาวเสมอขั้นที่ 1 กดอนุญาตขั้นที่ 2 แลกโทเคนขั้นที่ 3 ดึงรายการเพจผลที่ได้โทเคนผู้ใช้สั้นอายุราว 2 ชั่วโมงโทเคนผู้ใช้ยาวอายุราว 60 วันme/accountsคืนรายการเพจโทเคนเพจไม่มีวันหมดอายุส่งรหัสแอปกับค่าลับแอปไปด้วยข้ามขั้นที่ 2 แล้วโทเคนเพจหมดอายุตามโทเคนสั้น
ถ้าข้ามขั้นที่สอง โทเคนเพจที่ได้จะหมดอายุตามโทเคนสั้น ระบบพังในอีกสองชั่วโมง ทางที่ถูกต้องคือแลกเป็นโทเคนยาวก่อนเสมอ
<?php
// 1) โทเคนผู้ใช้แบบสั้น -> แบบยาว
$ll = fb_api('GET', 'oauth/access_token', [
    'grant_type'        => 'fb_exchange_token',
    'client_id'         => $cfg['app_id'],
    'client_secret'     => $cfg['app_secret'],
    'fb_exchange_token' => $short_user_token,
]);
if (empty($ll['access_token'])) {
    exit('แลกโทเคนยาวไม่สำเร็จ: ' . json_encode($ll, JSON_UNESCAPED_UNICODE));
}

// 2) โทเคนผู้ใช้แบบยาว -> รายการเพจ พร้อมโทเคนเพจ
$accts = fb_api('GET', 'me/accounts', ['fields' => 'id,name,access_token'], $ll['access_token']);
if (empty($accts['data'])) {
    exit('ไม่พบเพจที่เป็นผู้ดูแล หรือขอบเขตสิทธิ์ไม่พอ');
}
$page = $accts['data'][0];   // ถ้ามีหลายเพจ ให้เลือกด้วยรหัสเพจแทนการหยิบตัวแรก

// 3) สมัครรับเหตุการณ์ของเพจเข้าแอป
fb_api('POST', $page['id'] . '/subscribed_apps',
    ['subscribed_fields' => 'messages,messaging_postbacks,feed'],
    $page['access_token']);

ตัวเรียก Graph API ผมทำเป็นฟังก์ชันเดียวใช้ร่วมกันทั้งโปรเจกต์ สำคัญที่มันคืน array เสมอ ไม่โยนข้อผิดพลาดออกมา เพราะผู้เรียกต้องอ่านคีย์ error ได้ตลอด

<?php
function fb_api($method, $path, $params = [], $token = null) {
    $ver = 'v25.0';                         // ตรึงรุ่นไว้ อย่าใช้เส้นทางที่ไม่ระบุรุ่น
    $url = 'https://graph.facebook.com/' . $ver . '/' . ltrim($path, '/');
    if ($token) $params['access_token'] = $token;

    $ch = curl_init();
    if (strtoupper($method) === 'GET') {
        curl_setopt($ch, CURLOPT_URL, $url . '?' . http_build_query($params));
    } else {
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
    }
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 30);
    $res = curl_exec($ch);
    $err = curl_error($ch);
    curl_close($ch);

    if ($res === false) return ['error' => ['message' => $err ?: 'curl failed']];
    $j = json_decode($res, true);
    return is_array($j) ? $j : ['error' => ['message' => 'bad json', 'raw' => $res]];
}

การตรึงหมายเลขรุ่นของส่วนต่อประสานเป็นเรื่องที่ผมยืนยันว่าต้องทำ เพราะแพลตฟอร์มปลดระวางรุ่นเก่าเป็นรอบๆ ถ้าไม่ระบุรุ่น วันที่เขาเปลี่ยนรุ่นปริยาย ระบบเราจะเปลี่ยนพฤติกรรมเองโดยที่เราไม่ได้แก้อะไรเลย

โทเคนเพจต้องไม่อยู่ในที่ที่เว็บเสิร์ฟได้

โทเคนเพจที่ไม่มีวันหมดอายุคือของที่มีค่าที่สุดในระบบทั้งก้อน ใครได้ไปคือโพสต์ในนามเพจของลูกค้าได้ และอ่านข้อความในกล่องข้อความของเพจได้ทั้งหมด

ไฟล์โทเคนอยู่นอกรากเว็บ และตั้งสิทธิ์ให้เจ้าของอ่านได้คนเดียว

อย่าเก็บโทเคนไว้ในโฟลเดอร์ที่เว็บเสิร์ฟ แม้จะตั้งชื่อไฟล์ให้เดายากแค่ไหนก็ตาม เพราะวันที่เว็บเซิร์ฟเวอร์ตีความนามสกุลผิด หรือวันที่มีคนเปิดดูรายชื่อไฟล์ในโฟลเดอร์ได้ ของทั้งหมดหลุดพร้อมกัน ที่ถูกคือเก็บไว้ในโฟลเดอร์ที่อยู่เหนือรากเว็บขึ้นไปหนึ่งชั้น และตั้งสิทธิ์ไฟล์ให้เจ้าของอ่านเขียนได้คนเดียว ถ้าใช้โฮสต์ร่วมที่ย้ายไฟล์ออกไปไม่ได้จริงๆ ให้ปิดโฟลเดอร์ด้วยไฟล์กำหนดค่าของเว็บเซิร์ฟเวอร์ แล้วยังต้องยอมรับว่าชั้นป้องกันบางกว่า

<?php
// fb_config.php — ค่าลับทั้งหมดอ่านจากสภาพแวดล้อม ไม่มีค่าจริงอยู่ในซอร์สที่ commit
return [
    'app_id'       => getenv('FB_APP_ID'),
    'app_secret'   => getenv('FB_APP_SECRET'),
    'verify_token' => getenv('FB_VERIFY_TOKEN'),   // ใช้ตอนจับมือกับ webhook
    'api_secret'   => getenv('FB_INTERNAL_BEARER') // กันปลายทางภายในของเราเอง
];

// ที่เก็บโทเคนเพจ: อยู่เหนือรากเว็บขึ้นไปหนึ่งชั้น
function fb_token_file() { return dirname(__DIR__) . '/private/page_token.json'; }

ตอนเขียนไฟล์โทเคนครั้งแรก ผมสั่งตั้งสิทธิ์ไฟล์เป็น 0600 ทันทีในบรรทัดถัดจากการเขียน ไม่รอให้ใครมาตั้งให้ทีหลัง เพราะช่วงเวลาระหว่างที่ไฟล์เกิดกับที่สิทธิ์ถูกแก้ คือช่วงที่เพื่อนบ้านบนโฮสต์ร่วมอ่านได้

เหตุการณ์ที่ยิงเข้ามา ต้องตรวจลายเซ็นอีกชั้น

ปลายทางรับเหตุการณ์ทำงานสองแบบในไฟล์เดียว แบบ GET คือการจับมือตอนตั้งค่าครั้งแรก เราต้องเทียบค่าที่ตั้งไว้แล้วส่งค่าท้าทายกลับไปดิบๆ ส่วนแบบ POST คือเหตุการณ์จริง ซึ่งมาพร้อมลายเซ็นในส่วนหัว

<?php
// webhook.php
$cfg = require __DIR__ . '/fb_config.php';
require __DIR__ . '/_fbhelp.php';

if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
    $mode = $_GET['hub_mode'] ?? '';
    $tok  = $_GET['hub_verify_token'] ?? '';
    if ($mode === 'subscribe' && hash_equals($cfg['verify_token'], $tok)) {
        header('Content-Type: text/plain');
        echo $_GET['hub_challenge'] ?? '';   // ต้องส่งค่าท้าทายกลับดิบๆ ถึงจะผ่าน
        exit;
    }
    http_response_code(403);
    exit('verify failed');
}

// POST: เหตุการณ์จริง — ต้องเซ็นจาก "เนื้อความดิบ" ก่อน json_decode
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$ok  = false;
if (strpos($sig, 'sha256=') === 0) {
    $expected = 'sha256=' . hash_hmac('sha256', $raw, $cfg['app_secret']);
    $ok = hash_equals($expected, $sig);
}
if (!$ok) { http_response_code(403); exit('bad signature'); }

@file_put_contents(__DIR__ . '/_events.log',
    json_encode(['at' => date('c'), 'body' => json_decode($raw, true)], JSON_UNESCAPED_UNICODE) . "\n",
    FILE_APPEND | LOCK_EX);

http_response_code(200);
echo 'EVENT_RECEIVED';

ตอนทดสอบระยะแรก ผมเขียนบันทึกเหตุการณ์ลงไฟล์ไว้ก่อนโดยยังไม่ปฏิเสธคำขอที่ลายเซ็นไม่ผ่าน เพื่อจะได้เห็นว่าอะไรเข้ามาบ้าง แต่พอขึ้นใช้งานจริงต้องเปลี่ยนเป็นปฏิเสธทันทีเสมอ ไม่งั้นใครก็ยัดเหตุการณ์ปลอมเข้าระบบเราได้ ถ้าเดาที่อยู่ปลายทางถูก

สรุป

งานนี้ส่วนที่ยากไม่ใช่การเรียกส่วนต่อประสาน เพราะเอกสารเขาชัดและตัวอย่างมีครบ ส่วนที่ยากคือของสามอย่างที่ไม่เกี่ยวกับฟีเจอร์ของเราเลย คือปลายทางบังคับสามตัว การตรวจลายเซ็นให้ถูกวิธี และการเก็บโทเคนให้มิดชิด

ส่วนตัวผมสรุปเป็นสามข้อสั้นๆ ข้อแรกทำปลายทางบังคับให้ครบก่อนกดส่งรีวิว เขียนสามไฟล์ใช้เวลาไม่ถึงชั่วโมง แต่รีวิวตกหนึ่งรอบเสียเวลาเป็นสัปดาห์ ข้อสองต้องแลกเป็นโทเคนผู้ใช้แบบยาวก่อนเสมอ แล้วค่อยไปขอโทเคนเพจ ไม่งั้นระบบตายในสองชั่วโมง ข้อสามโทเคนเพจอยู่นอกรากเว็บเท่านั้น เพราะมันคือกุญแจของเพจลูกค้า ไม่ใช่ของเรา

ใครจะเลือกใช้บริการของผู้ให้บริการที่ผ่านรีวิวมาแล้วแทนการทำเองก็ตามสะดวกครับ ถ้าสิ่งที่ต้องการคือตอบข้อความอัตโนมัติเฉยๆ ทางนั้นคุ้มกว่าจริงๆ

ปล. เรื่องที่ผมยังไม่ได้เขียนในรอบนี้คือการรับมือตอนโทเคนเพจใช้ไม่ได้กลางทาง เช่นเจ้าของเพจถอดสิทธิ์ผู้ดูแลของเราออก ตอนนี้ระบบแค่บันทึกข้อผิดพลาดไว้เฉยๆ ซึ่งแปลว่าอาจเงียบไปหลายชั่วโมงกว่าจะมีคนสังเกต ที่ถูกควรตรวจรหัสข้อผิดพลาดแล้วแจ้งเตือนทันที เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย