Skip to content

13.9 Static assets, file storage, upload path

บทนี้สำรวจว่าไฟล์ที่ผู้ใช้อัปโหลด, ไฟล์ PDF payslip, และ static asset อื่นถูกเก็บที่ใด, เสิร์ฟอย่างไร, และจุดที่มีความเสี่ยง

ภาพรวม file storage

┌─────────────────────────────────────────────────────────────────────┐
│  vtrc-api container                                                 │
│                                                                     │
│  /usr/src/app/storage/   ←── volume: ./api-storage (host)          │
│    ├── prs/              ──── PDF payslip + tax                     │
│    ├── {folder}/{subfolder}/  ─── upload ทั่วไป                     │
│    └── ...                                                          │
│                                                                     │
│  /usr/src/app/src/storage/prs/  ─── บาง code path ใช้ (bug!)       │
│                                                                     │
│  /usr/src/app/logs/      ←── volume: ./api-logs (host)             │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  nginx (vtrc-web-proxy)                                             │
│                                                                     │
│  /usr/share/nginx/html/          ←── vtrc-web build (static)        │
│  /usr/share/nginx/html-app-dn/   ←── vtrc-mobile-app (static)       │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

vtrc-api — การเก็บไฟล์ใน storage/

ไฟล์ที่สร้างหรือรับจากผู้ใช้ถูกเก็บใน directory storage/ ภายใต้ working directory ของ service ซึ่งก็คือ process.env.PWD ในขณะที่รัน

vtrc-api/api/src/lib/repositories/file/file.js:113-115:

javascript
var pathFolder = `${process.env.PWD}/storage/${folder}`
var fullPath = `${process.env.PWD}/storage/${folder}/${subfolder}`

vtrc-api/api/src/routes.js:904:

javascript
const filePath = path.resolve(process.env.PWD, `storage/prs/${id}`);

จุดสังเกต:

  1. process.env.PWD ไม่ใช่ __dirnamePWD เป็น environment variable ที่ shell กำหนดตอนเริ่ม process ค่านี้อาจไม่ตรงกับ directory ของ source code ถ้าเริ่ม service จาก path อื่น

  2. storage/prs/ เป็น subdirectory หลัก สำหรับ PDF payslip — เข้าถึงได้ผ่าน REST endpoint /readfile/prs/...

  3. บาง code path ใช้ src/storage/prs/ แทน storage/prs/:

    • vtrc-api/api/src/lib/genSlip/index.js:59
    • vtrc-api/api/src/lib/createtax/index.js:26,145,311,598
    • vtrc-api/api/src/lib/createslip/index.js:62
    • vtrc-api/api/src/lib/createfailpayment/index.js:49
    • vtrc-api/api/src/lib/controllers/withdraw/hospital.report.js:78

    ความขัดแย้งของ path นี้บ่งชี้ว่าอาจมีบั๊กที่ทำให้ไฟล์ถูกเขียนไปที่ผิด หรือมีการย้าย code แล้ว path ไม่ได้ปรับตาม — ควรตรวจสอบ

vtrc-api — express.static

vtrc-api/api/src/routes.js:986-988:

javascript
app.use('/resources', express.static(process.env.PWD + '/storage/prs'));
app.use('/', express.static(process.env.PWD + '/storage'));

จุดสังเกต:

  1. / expose ทั้ง storage/ ผ่าน HTTP — ทุกไฟล์ใน storage/ สามารถเข้าถึงได้โดยไม่ต้องผ่าน authentication เช่น https://vtrcapi.redcross.or.th/<filename> เรียกไฟล์ใด ๆ ใน storage ได้โดยตรง

  2. /resources เป็น alias ของ storage/prs — เส้นทางสั้นสำหรับ payslip PDF

  3. ไม่มี authorization check ใน static file — ใครก็ตามที่รู้ URL สามารถดาวน์โหลดไฟล์ได้

vtrc-api — /readfile/prs/ endpoint สำหรับ PDF payslip

vtrc-api/api/src/routes.js:51:

javascript
app.get(`${API_PREFIX_PATH}/readfile/prs/:year/:month/:pid/:empcode/:device/:timestamp/:hash`, async function (req, res) {

URL pattern:

/api/graphql/readfile/prs/[year]/[month]/[pid]/[empcode]/[device]/[timestamp]/[hash]

จุดสังเกต:

  1. pid = identityCard และ empcode — PII อยู่ใน URL path ทำให้ปรากฏใน nginx access log, browser history, referrer header (รายละเอียด Volume 9 บทที่ 1)

  2. hash เป็น signed URL token — ระบบสร้าง hash ที่มี TTL 1 ชั่วโมง ถ้า hash หมดอายุหรือไม่ตรงจะปฏิเสธ (รายละเอียด Volume 11 บทที่ 4)

  3. timestamp ใช้ตรวจสอบอายุของ URL — เปรียบเทียบกับเวลาปัจจุบัน

vtrc-api — volume mount สำหรับ storage

vtrc-api/docker-compose.yml:57-59,70-72,84-85,97-98:

yaml
volumes:
  - ./api-storage:/usr/src/app/storage
  - ./api-logs:/usr/src/app/logs

จุดสังเกต:

  1. ./api-storage บน host mount เข้าทุก replica — ทำให้ไฟล์ที่เขียนใน replica 1 มองเห็นได้ใน replica 2, 3, 5
  2. Volume เดียวกัน, หลาย writer — เสี่ยง race condition ถ้าหลาย replica เขียนไฟล์ชื่อเดียวกันพร้อมกัน (แต่ในทางปฏิบัติ PDF payslip มี path ที่ไม่ซ้ำกันตาม pid/year/month)
  3. Backup — directory ./api-storage อยู่บน host ต้อง backup ด้วย rsync หรือเครื่องมืออื่น — ไม่มี backup automation ที่เห็น

vtrc-api — PDF retention policy

vtrc-api/api/src/routes.js:79-82:

javascript
// หลังจากส่งไฟล์แล้วลบใน 5 วินาที
setTimeout(() => {
  // delete PDF
}, 5000);

จุดสังเกต:

  1. setTimeout(deletePDF, 5000) — ลบไฟล์หลัง 5 วินาที ถ้า download ใช้เวลานานกว่านั้น ไฟล์จะถูกลบระหว่าง stream ทำให้ download ไม่สำเร็จ (รายละเอียด Volume 11 บทที่ 4)

  2. ไม่ได้ใช้ onFinish ของ stream — ที่เป็นวิธีที่ถูกต้องในการลบไฟล์หลัง stream เสร็จ

nginx — static file serving

สำหรับ frontend ทั้งสองตัว, static file ถูกเสิร์ฟโดย nginx โดยตรง ไม่ผ่าน Node

vtrc-api/nginx/default.conf:250-254:

nginx
location / {
    root   /usr/share/nginx/html;
    index  index.html index.htm;
    try_files $uri $uri/ /index.html;
}

vtrc-api/nginx/default.conf:246-249:

nginx
location /adn/ {
    root   /usr/share/nginx/html-app-dn;
    index  index.html index.htm;
}

จาก vtrc-api/docker-compose.yml:22-23:

yaml
- /home/dev/vtrc-web/build:/usr/share/nginx/html
- /home/dev/vtrc-mobile-app:/usr/share/nginx/html-app-dn/adn

จุดสังเกต:

  1. /home/dev/vtrc-web/build — path ที่ mount บ่งชีบว่าเคยเป็น dev environment ที่เก็บ build เอาไว้ (ทั้งใน prod) — ชื่อ dev ใน path ทำให้สับสน

  2. /home/dev/vtrc-mobile-app — มี static file ของ mobile app ที่ mount ผ่าน nginx เดียวกับเว็บ

centralize-api — file storage

centralize-api เป็น NestJS service ที่ส่วนใหญ่ทำงานกับ MSSQL — ไม่มีการจัดการ file upload ที่ซับซ้อน แต่มีการใช้ SFTP สำหรับ transfer ไฟล์:

centralize-api/src/ มี service ที่เกี่ยวข้องกับ SFTP สำหรับการ export ข้อมูล — รายละเอียดอยู่ใน Volume 5

cu-central-api — file transfer ผ่าน SCP

vtrc-api/api/.env.example:26-32:

TRANSFER_PROTOTAL="scp"
TRANSFER_HOST=""
TRANSFER_PORT="22"
TRANSFER_USERNAME=""
TRANSFER_PASSWORD=""
TRANSFER_DIRECT_DEBIT_PATH=""
TRANSFER_PAYROLL_PATH=""

จุดสังเกต:

  1. TRANSFER_PROTOTAL — typo ของ PROTOCOL ที่ใช้มานานแล้ว เปลี่ยนไม่ได้เพราะจะกระทบ code ที่อ้างถึง env var นี้

  2. SCP สำหรับ direct debit และ payroll file — ระบบสร้างไฟล์แล้ว SCP ไปยัง server ของธนาคาร หรือระบบอื่น

  3. Credentials อยู่ใน env — username/password ของ SCP server อยู่ใน environment variable ไม่ได้ใช้ SSH key

สรุป path ที่ใช้

Pathบนที่ไหนบทบาท
storage/prs/vtrc-api containerPDF payslip และ tax
storage/{folder}/{subfolder}/vtrc-api containerupload ทั่วไป
src/storage/prs/vtrc-api (บาง code path)bug — ควรเป็น storage/prs/
logs/vtrc-api, cu-central-apilog file
/usr/share/nginx/htmlnginx vtrc-webvtrc-web build
/usr/share/nginx/html-app-dnnginx vtrc-webvtrc-mobile-app
/usr/share/nginx/html/buildnginx backofficebackoffice build
./cert/host (mount เข้า nginx)SSL certificate

ปัญหาเชิงปฏิบัติการ

ปัญหาที่มาผลกระทบ
express.static('/', storage) ไม่มี authroutes.js:988ไฟล์ใน storage เข้าถึงได้โดยไม่ต้อง login
src/storage/prs/ vs storage/prs/หลาย code pathไฟล์ถูกเขียนที่ผิด — bug ที่ยังไม่ถูกตรวจสอบ
setTimeout(deletePDF, 5000)routes.js:79download ช้าทำให้ไฟล์หายกลางทาง
PII ใน URLroutes.js:51ปรากฏใน log, history, referrer
Volume share หลาย replicadocker-composerace condition ตอนเขียน
SCP ด้วย password ไม่ใช่ key.env.example:29-30credential รั่วง่าย
PWD แทน __dirnameหลายไฟล์path เปลี่ยนถ้า start จาก directory อื่น
TRANSFER_PROTOTAL typo.env.example:26ภาระในการ maintain ในอนาคต

ข้อแนะนำเพื่อปรับปรุง

  1. ย้าย file storage ไป object storage — GCS หรือ S3 แทน volume ที่ mount
  2. เพิ่ม auth ใน static endpoint — ใช้ signed URL ที่มี expiry และ scope จำกัด
  3. แก้ path src/storage/prs/ ให้เป็น storage/prs/ — หรือกลับกัน แต่ต้องทำให้ตรงทุกที่
  4. เปลี่ยน setTimeout เป็น onFinish — ลบไฟล์หลัง stream เสร็จจริง
  5. ใช้ SSH key แทน password สำหรับ SCP — หรือย้ายไปใช้ API ของธนาคารถ้าเป็นไปได้
  6. เพิ่ม CDN หน้า nginx — สำหรับ static file ที่ไม่ใช่ PII เพื่อลดภาระ nginx
  7. เปลี่ยนจาก PWD เป็น __dirname หรือ env var ที่กำหนดชัดเจน — ทำให้ path คาดเดาได้