Skip to content

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

js
// 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

graphql
# typeDefs/root.js:8
scalar Upload

ตัวอย่าง field ที่ใช้ Upload

graphql
# typeDefs/post.js:42
createPost(fileImage: Upload!): Post

# typeDefs/document.js:51
createDocument(file: Upload): Document

# typeDefs/wdHospital.js:398
importUpdateStatus(file: Upload!): ResposeStatus

Upload 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

js
// 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 namemaxCountAuth
POST /upload501(single)1apiKey
POST /hospitalslip/upload525attachFiles100apiKey + JWT
POST /hospitaldocument/upload580attachFilesapiKey + JWT
POST /leave/attachment/draft633attachFiles
POST /leave/attachment677attachFilesapiKey + JWT
PUT /leave/attachment729attachFilesapiKey + JWT

ข้อจำกัด

  • memoryStorage — ไฟล์อยู่ใน RAM จนกว่าจะเขียน disk หรือ forward
  • ไม่มี limit ขนาด — ใช้ default ของ multer (ไม่จำกัด)
  • /leave/attachment และ /leave/attachment/draft ส่งต่อไป cu-central-api — เป็น proxy

3. Storage — local disk เท่านั้น

Path convention

js
// 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ความหมาย
idUUID หรือ auto increment
fileNameชื่อไฟล์เดิม (เช่น payslip_july.pdf)
fileTypeMIME type
filePathpath บน disk

Static serving

js
// 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)

js
// 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)

js
// lib/controllers/file/file.js:154-185
export const saveFilev2 = async (file, ..., transaction) => {
  // เหมือน v1 แต่รับ transaction มาด้วย
  // ถ้า rollback → ไฟล์บน disk ยังอยู่ (ไม่กระทบ)
}

saveFileRF (REST stream)

js
// 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

js
// 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

js
// 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

EndpointPDF typeบรรทัด
GET /readfile/prs/...Payslip51
GET /tax/...Tax101
GET /tax6month/...6-month tax205
GET /taxyears/...Annual tax255
GET /salarycert/...Salary cert305
GET /summary/:empCode/summaryWelfareReportWelfare report995
GET /fund/ratechange/summaryFund rate-change1065
GET /fund/benefitperson/summaryFund beneficiary1142
GET /fund/benefitperson/formBeneficiary form1280

Signed URL pattern

/readfile/prs/{year}/{month}/{pid}/{empcode}/{device}/{timestamp}/{hash}
  • timestamp — unix ms ตอน sign
  • hash — 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)  (ลบไฟล์อัตโนมัติ)

ที่มา

js
// 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

EndpointExport typeบรรทัด
GET /download/excel/:tableExcel497
GET /wdHospital/exportZIP (KBank + SmartCredit + FMIS)772
POST /wdHospital/export/wdpaidZIP (paid welfare)835
GET /wdHospital/report/sumorgJSON (summary)876
GET /fund/application/excelExcel ZIP (Tisco)1219
POST /fund/ratechange/excelExcel ZIP1233

Pattern — ZIP generation

js
// ตัวอย่าง 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=20k

trigger ส่งไฟล์ long-term training ไป Conicle — ไม่ต้อง auth (gated ด้วย magic param)


เปรียบเทียบ upload channels

ด้านGraphQL UploadREST multer
Transportmultipart ใน GraphQL querymultipart/form-data
Limit20 MB / 100 filesไม่จำกัด (default multer)
Storagememory → diskmemory → disk หรือ forward
Use caseฟอร์มที่มี upload + mutationupload ลำดับเดียว
Validationresolver ตรวจเองroute handler ตรวจเอง
Files ในก้อนcpUpload รับ 100 filesแล้วแต่ route

ข้อระวังทั้งหมด

1. ไม่มี object storage

  • ไฟล์ทั้งหมดอยู่ใน disk ของ server
  • ถ้า disk เต็ม → upload ล้มเหลว
  • ไม่มี backup อัตโนมัติ (ถ้าไม่ได้ตั้ง)

2. ไม่มี virus scan

  • ไฟล์ถูกเก็บทันทีที่ upload
  • ผู้ใช้สามารถอัปโหลด malware

3. Static serving เปิดหมด

js
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 ได้ง่าย

การปรับปรุงที่แนะนำ

ระยะสั้น

  1. เพิ่ม auth บน static serving — ตรวจ JWT ก่อนส่งไฟล์
  2. เพิ่ม file type whitelist — ปฏิเสธไฟล์ที่ไม่ใช่ประเภทที่คาดไว้
  3. เปลี่ยน memoryStorage → diskStorage — ลด OOM risk

ระยะกลาง

  1. ย้ายไป S3/GCS — ใช้ presigned URL แทน signed URL เฉพาะ server
  2. เพิ่ม ClamAV scan — สแกนไฟล์ก่อนเก็บ
  3. เพิ่ม rate limit — จำกัด upload/download ต่อ user

ขั้นตอนถัดไป

ไป บท 10.9 Forward/proxy pattern