11.8 · File upload & download + PDF generation
บทนี้รวบรวมกลไก upload, download, และ PDF generation ของ vtrc-api — เป็นเรื่องที่ใช้ทั้ง GraphQL และ REST และมีความซับซ้อนเป็นพิเศษ
ภาพรวม
vtrc-api มี 2 ช่องทาง upload และ 3 ประเภท download:
Upload:
├── GraphQL Upload scalar (apollo-upload) — ผ่าน /api/graphql
└── REST multer (memoryStorage) — ผ่าน /upload, /hospitalslip/upload, ...
Download:
├── File render/download (/file/:id, /file/:id/download)
├── PDF generation (payslip, tax, salary cert — qpdf-encrypted)
└── Export ZIP (Excel + multiple files)
Storage:
└── Local disk only — vtrc-api/api/storage/<shard>/<fileId>/<hash>.<ext>1. Upload — GraphQL Upload scalar
Setup
// index.js:25
app.use(
API_SERVICE_PATH,
graphqlUploadExpress({ maxFileSize: 20_000_000, maxFiles: 100 }),
server
)maxFileSize: 20_000_000(20 MB)maxFiles: 100
Scalar declaration
# typeDefs/root.js:8
scalar Uploadตัวอย่าง field ที่ใช้ Upload
# typeDefs/post.js:42
createPost(fileImage: Upload!): Post
# typeDefs/document.js:51
createDocument(file: Upload): Document
# typeDefs/wdHospital.js:398
importUpdateStatus(file: Upload!): ResposeStatusUpload flow (GraphQL)
1. client ส่ง multipart/form-data ไป /api/graphql
ภายในมี operations + map ที่บอกว่า file ไหนเป็น variable ไหน
↓
2. graphqlUploadExpress แยกไฟล์ออกมาเป็น Promise<Upload>
↓
3. Resolver เรียก file.createReadStream() หรือ await file
↓
4. Controller (lib/controllers/file/file.js) เรียก saveFile() หรือ saveFilev2()
↓
5. เขียนลง disk ที่ storage/<shard>/<fileId>/<hash>.<ext>
↓
6. สร้าง File row ใน MariaDB
↓
7. ส่งกลับ file ID ให้ clientข้อจำกัด
- ทำงานใน memory ก่อนเขียน disk — ไฟล์ใหญ่อาจทำให้ Node OOM
- ไม่มี virus scan — ไฟล์ถูกเก็บทันที
- ไม่มี file type check — รับทุกประเภท
2. Upload — REST multer
Setup
// routes.js:43-45
const upload = multer({
storage: multer.memoryStorage()
})
const cpUpload = upload.fields([{ name: 'attachFiles', maxCount: 100 }])multer.memoryStorage() — เก็บไฟล์ใน RAM ทั้งหมด ไม่เขียน disk อัตโนมัติ
Endpoints ที่ใช้ multer
| Endpoint | บรรทัด | field name | maxCount | Auth |
|---|---|---|---|---|
POST /upload | 501 | (single) | 1 | apiKey |
POST /hospitalslip/upload | 525 | attachFiles | 100 | apiKey + JWT |
POST /hospitaldocument/upload | 580 | attachFiles | — | apiKey + JWT |
POST /leave/attachment/draft | 633 | attachFiles | — | — |
POST /leave/attachment | 677 | attachFiles | — | apiKey + JWT |
PUT /leave/attachment | 729 | attachFiles | — | apiKey + JWT |
ข้อจำกัด
- memoryStorage — ไฟล์อยู่ใน RAM จนกว่าจะเขียน disk หรือ forward
- ไม่มี limit ขนาด — ใช้ default ของ multer (ไม่จำกัด)
/leave/attachmentและ/leave/attachment/draftส่งต่อไป cu-central-api — เป็น proxy
3. Storage — local disk เท่านั้น
Path convention
// lib/controllers/file/file.js:9-10
const upload_dir = __dirname + '../../../../../storage'
const upload_dir_api_storage = __dirname + '../../../../../../api-storage'รายละเอียด path:
storage/
├── ab/
│ └── abc123def456/
│ └── a8f7b2c9d1.pdf
├── cd/
│ └── cde789fgh012/
│ └── e5d4c3b2a1.jpg
└── prs/ ← static served ที่ /resources
└── ...- 2 ตัวอักษรแรกของ fileId เป็นชื่อโฟลเดอร์ (shard) เพื่อกระจายไฟล์
- แต่ละไฟล์อยู่ในโฟลเดอร์ fileId ของตัวเอง
- ชื่อไฟล์จริงเป็น hash สุ่ม
File table
MariaDB File table เก็บ metadata:
| Field | ความหมาย |
|---|---|
id | UUID หรือ auto increment |
fileName | ชื่อไฟล์เดิม (เช่น payslip_july.pdf) |
fileType | MIME type |
filePath | path บน disk |
Static serving
// routes.js:986-988
app.use('/resources', express.static(PWD + '/storage/prs'))
app.use('/', express.static(PWD + '/storage'))ผล: ไฟล์ใน storage/ เข้าถึงได้โดยตรงผ่าน HTTP — เช่น https://vtrcapi.redcross.or.th/ab/abc123/a8f7b2c9d1.pdf
ข้อระวัง: ถ้ามีไฟล์ sensitive ใน storage/ อาจรั่ว (ไม่มี auth check ตอน static serve)
4. File save — 2 versions
saveFile (v1)
// lib/controllers/file/file.js:121-152
export const saveFile = async (file, ...) => {
const { filename, mimetype, createReadStream } = await file
const stream = createReadStream()
// อ่าน stream → เขียน disk
// สร้าง File row
return fileId
}saveFilev2 (v2 — ใช้ transaction)
// lib/controllers/file/file.js:154-185
export const saveFilev2 = async (file, ..., transaction) => {
// เหมือน v1 แต่รับ transaction มาด้วย
// ถ้า rollback → ไฟล์บน disk ยังอยู่ (ไม่กระทบ)
}saveFileRF (REST stream)
// lib/controllers/file/file.js:256-285
export const saveFileRF = async (req, ...) => {
// รับ request stream โดยตรง (ไม่ผ่าน multer)
}5. Download — File render/download
GET /file/:id — render inline
// routes.js:389-462 (concept)
app.get('/file/:id', async (req, res) => {
// ตรวจ apiKey (prod keys เท่านั้น)
const mapKey = API_DEVICE_KEY['prod'].filter(value => value.key === req.headers.apikey)
if (mapKey.length === 0) return res.sendStatus(404)
// ดึง file path จาก DB
const file = await File.findByPk(req.params.id)
if (!file) return res.sendStatus(404)
// ส่งไฟล์ (inline — เปิดใน browser)
res.setHeader('Content-Type', file.fileType)
res.setHeader('Content-Disposition', `inline; filename="${file.fileName}"`)
fs.createReadStream(file.filePath).pipe(res)
})GET /file/:id/download — force download
// routes.js:464-495 (concept)
// เหมือนข้างบน แต่ Content-Disposition: attachment
res.setHeader('Content-Disposition', `attachment; filename="${file.fileName}"`)ข้อระวัง
/file/:idตรวจ apiKey เฉพาะ prod keys — UAT key ใช้ไม่ได้/file/:id/downloadไม่ตรวจ auth — ใครมี ID ก็ดาวน์โหลดได้
6. PDF generation — signed URL + qpdf encryption
Endpoints ที่ generate PDF
| Endpoint | PDF type | บรรทัด |
|---|---|---|
GET /readfile/prs/... | Payslip | 51 |
GET /tax/... | Tax | 101 |
GET /tax6month/... | 6-month tax | 205 |
GET /taxyears/... | Annual tax | 255 |
GET /salarycert/... | Salary cert | 305 |
GET /summary/:empCode/summaryWelfareReport | Welfare report | 995 |
GET /fund/ratechange/summary | Fund rate-change | 1065 |
GET /fund/benefitperson/summary | Fund beneficiary | 1142 |
GET /fund/benefitperson/form | Beneficiary form | 1280 |
Signed URL pattern
/readfile/prs/{year}/{month}/{pid}/{empcode}/{device}/{timestamp}/{hash}timestamp— unix ms ตอน signhash— HMAC ของ (timestamp + empcode + pid) กับ server secret- timeout 1 ชั่วโมง — ถ้าเกิน → 404
Flow
1. client ขอ PDF → vtrc-api สร้าง signed URL ส่งกลับ
↓
2. client ใช้ URL ภายใน 1 ชม. เปิดใน browser หรือ iframe
↓
3. vtrc-api ตรวจ timestamp + HMAC
↓
4. ดึงข้อมูลจาก DB → render PDF (ด้วย lib เช่น pdfmake)
↓
5. qpdf-encrypt PDF ด้วย password (เช่น identityCard ของ user)
↓
6. res.sendFile(pdfPath)
↓
7. หลัง 5 วินาที → deletePDF(pdfPath) (ลบไฟล์อัตโนมัติ)ที่มา
// routes.js:73-82 (payslip concept)
res.sendFile(pdfPath)
setTimeout(() => deletePDF(pdfPath), 5000)qpdf encryption
PDF ถูกเข้ารหัสด้วย qpdf — ผู้ใช้ต้องกรอก password (เช่น เลขบัตรประชาชน) ตอนเปิด
ทำไม: ถ้า URL รั่ว — PDF ยังเข้าไม่ได้ถ้าไม่รู้ password
ข้อระวัง
- timeout 1 ชม — ถ้า user เปิด PDF หลัง 1 ชม. → 404
- deletePDF 5 วิ — ถ้า download ช้ากว่า 5 วิ → ไฟล์หาย
- password = identityCard — ถ้าเลขบัตรรั่ว → PDF รั่ว
7. Export — ZIP + Excel
Endpoints
| Endpoint | Export type | บรรทัด |
|---|---|---|
GET /download/excel/:table | Excel | 497 |
GET /wdHospital/export | ZIP (KBank + SmartCredit + FMIS) | 772 |
POST /wdHospital/export/wdpaid | ZIP (paid welfare) | 835 |
GET /wdHospital/report/sumorg | JSON (summary) | 876 |
GET /fund/application/excel | Excel ZIP (Tisco) | 1219 |
POST /fund/ratechange/excel | Excel ZIP | 1233 |
Pattern — ZIP generation
// ตัวอย่าง concept
app.get('/wdHospital/export', async (req, res) => {
const archive = archiver('zip')
res.setHeader('Content-Type', 'application/zip')
res.setHeader('Content-Disposition', 'attachment; filename="export.zip"')
archive.pipe(res)
// เพิ่มไฟล์หลายไฟล์
archive.append(kbankExcel, { name: 'KBank.xlsx' })
archive.append(smartCreditExcel, { name: 'SmartCredit.xlsx' })
archive.append(fmisExcel, { name: 'FMIS.xlsx' })
archive.finalize()
})ข้อระวัง
- ไม่มี auth — ใครก็เรียกได้ (ข้อมูลอาจ sensitive)
- Generation ใน memory — ถ้า export ใหญ่ OOM
- ไม่มี rate limit — DDoS ได้
8. Conicle integration — SFTP
GET /conicle/downloadFile/:id
ดาวน์โหลดไฟล์จาก Conicle LMS ผ่าน session cookie ของ cu-central-api
GET /conicle/getFileFromUrl
Proxy fetch จาก Conicle URL
SFTP — cu-central-api
cu-central-api → Conicle SFTP
POST /conicle/longterm?runLong=20ktrigger ส่งไฟล์ long-term training ไป Conicle — ไม่ต้อง auth (gated ด้วย magic param)
เปรียบเทียบ upload channels
| ด้าน | GraphQL Upload | REST multer |
|---|---|---|
| Transport | multipart ใน GraphQL query | multipart/form-data |
| Limit | 20 MB / 100 files | ไม่จำกัด (default multer) |
| Storage | memory → disk | memory → disk หรือ forward |
| Use case | ฟอร์มที่มี upload + mutation | upload ลำดับเดียว |
| Validation | resolver ตรวจเอง | route handler ตรวจเอง |
| Files ในก้อน | cpUpload รับ 100 files | แล้วแต่ route |
ข้อระวังทั้งหมด
1. ไม่มี object storage
- ไฟล์ทั้งหมดอยู่ใน disk ของ server
- ถ้า disk เต็ม → upload ล้มเหลว
- ไม่มี backup อัตโนมัติ (ถ้าไม่ได้ตั้ง)
2. ไม่มี virus scan
- ไฟล์ถูกเก็บทันทีที่ upload
- ผู้ใช้สามารถอัปโหลด malware
3. Static serving เปิดหมด
app.use('/', express.static(PWD + '/storage'))- ไฟล์ใน
storage/เข้าถึงได้โดยไม่ต้อง auth - ถ้ามีไฟล์ sensitive → รั่ว
4. PDF timeout 1 ชม
- ถ้า user คลิก link ในอีเมลหลัง 1 ชม. → 404
- ต้องขอ signed URL ใหม่
5. /file/:id/download ไม่ต้อง auth
- ใครมี file ID ก็ดาวน์โหลดได้
- ถ้า ID ทายง่าย (sequential) → enumeration
6. memoryStorage OOM risk
- multer เก็บไฟล์ใน RAM
- ถ้ามี upload 100 files × 100 MB พร้อมกัน → 10 GB RAM
- ควรเปลี่ยนเป็น diskStorage
7. ไม่มี rate limit
- ไม่มีการจำกัดจำนวน upload/download ต่อ user
- DDoS ได้ง่าย
การปรับปรุงที่แนะนำ
ระยะสั้น
- เพิ่ม auth บน static serving — ตรวจ JWT ก่อนส่งไฟล์
- เพิ่ม file type whitelist — ปฏิเสธไฟล์ที่ไม่ใช่ประเภทที่คาดไว้
- เปลี่ยน memoryStorage → diskStorage — ลด OOM risk
ระยะกลาง
- ย้ายไป S3/GCS — ใช้ presigned URL แทน signed URL เฉพาะ server
- เพิ่ม ClamAV scan — สแกนไฟล์ก่อนเก็บ
- เพิ่ม rate limit — จำกัด upload/download ต่อ user