excel

문서 › 함수와 Node

서버 2차 검수 (Node 예제)

브라우저가 보낸 정리된 행을 같은 rules.json으로 validate()에 다시 넣습니다. Express 예제, 실행 결과, 서버 보안 설정.

브라우저의 <excel-82>(또는 check())가 1차 검수를 하고 정리된 행을 보냅니다. 서버는 파일을 다시 열지 않고, 같은 rules.json 으로 validate() 를 다시 돌립니다.

브라우저                                         서버
check(file, rules.json)  ── { schema, rows, report } ──▶  1. schema.hash == rulesHash(rules.json) ?  아니면 409
  (1차: 파일·머리글·칸)        (+ file, upload-file=include)  2. validate(rows, rules.json)            실패면 422 + CSV
                                                          3. (파일을 받았으면) sha256(file) == report.file.sha256 ?
                                                          4. result.rows 저장

규칙 파일

브라우저(<excel-82 rules="/rules.json">)와 서버가 같은 파일을 씁니다.

{
  "$schema": "https://82excel.com/schema/rules-1.json",
  "id": "orders-upload",
  "version": "1",
  "columns": [
    { "key": "orderNo", "header": "주문번호", "nullable": false, "unique": true, "pattern": "^[A-Z]{2}\\d{6}$" },
    { "key": "date", "header": "주문일", "type": "date", "nullable": false },
    { "key": "phone", "header": "전화번호", "type": "kr.phone", "phone": { "kind": "mobile" } },
    { "key": "amount", "header": "금액", "type": "kr.amount", "min": 0 }
  ],
  "rows": { "min": 1, "max": 10000 }
}

서버 (server.mjs)

// server.mjs — the second pass: re-check what the browser sent, with the same rules.json.
import express from 'express';
import { readFile } from 'node:fs/promises';
import { makeErrorReport, rulesHash, sha256, validate } from '82excel';

const rules = JSON.parse(await readFile(new URL('./rules.json', import.meta.url), 'utf8'));
const RULES_HASH = await rulesHash(rules); // the same value as the browser's report.schemaHash
// validate() reports rows.max itself (ROWS_TOO_MANY), but only after reading every row. The browser never
// sends blank rows, so refusing longer arrays up front costs a real upload nothing.
const MAX_ROWS = rules.rows?.max ?? 10000;

/** validate() and, on failure, a 422 with the CSV error report. Returns the cleaned rows or null. */
async function recheck(rows, res) {
  if (Array.isArray(rows) && rows.length > MAX_ROWS) {
    res.status(413).json({ error: `at most ${MAX_ROWS} rows` });
    return null;
  }
  let result;
  try {
    result = validate(rows, rules); // throws "82excel: …" when rows is not an array of plain objects
  } catch (e) {
    res.status(400).json({ error: e.message });
    return null;
  }
  // Cell and row rules, rows.min / rows.max and failOn: the same errors as the browser's check().
  if (!result.ok) {
    const csv = Buffer.from(await makeErrorReport(result, 'csv').arrayBuffer());
    res.status(422).type('text/csv; charset=utf-8').attachment('errors.csv').send(csv);
    return null;
  }
  return result.rows;
}

const app = express();

// 1) Default upload (upload-as="json"): { fileName, sheet, schema, columns, rows, report }.
app.post('/api/orders', express.json({ limit: '20mb' }), async (req, res) => {
  const body = req.body ?? {};
  if (body.schema?.hash !== RULES_HASH) return res.status(409).json({ error: 'rules changed; reload the page' });
  const rows = await recheck(body.rows, res);
  if (!rows) return;
  // body.report is only a hint (it came from the browser): log it, never trust it.
  res.json({ saved: rows.length, first: rows[0] });
});

// 2) upload-file="include": multipart with file, data (the JSON above without report) and report.
app.post('/api/orders-with-file', express.raw({ type: 'multipart/form-data', limit: '60mb' }), async (req, res) => {
  // Node's own multipart parser (undici); multer or busboy work just as well.
  let form, data, report;
  try {
    form = await new Request('http://local/', {
      method: 'POST',
      headers: { 'content-type': req.get('content-type') },
      body: req.body,
    }).formData();
    data = JSON.parse(String(form.get('data') ?? '{}'));
    report = JSON.parse(String(form.get('report') ?? '{}'));
  } catch {
    return res.status(400).json({ error: 'malformed multipart body or JSON field' }); // not a 500
  }
  const file = form.get('file');
  if (!(file instanceof File)) return res.status(400).json({ error: 'file is missing' });
  if (data?.schema?.hash !== RULES_HASH) return res.status(409).json({ error: 'rules changed; reload the page' });
  const bytes = new Uint8Array(await file.arrayBuffer());
  if ((await sha256(bytes)) !== report?.file?.sha256) return res.status(400).json({ error: 'file does not match the report' });
  const rows = await recheck(data.rows, res);
  if (!rows) return;
  res.json({ saved: rows.length, file: file.name, bytes: bytes.length });
});

const server = app.listen(process.env.PORT ?? 3000, () => console.log(`listening on ${server.address().port}`));
export default server;
  • 기본 업로드(/api/orders). 0.2 규칙을 쓰는 <excel-82 rules=… upload=…> 는 JSON { fileName, sheet, schema: { id, version, hash }, columns, rows, report } 를 보냅니다. express.json({ limit }) 로 받고, 한도는 예상 행 수에 맞게 정하세요.
  • 해시 비교. body.schema.hash 와 await rulesHash(rules) 가 다르면, 사용자가 예전 규칙으로 검사한 페이지에서 보낸 것입니다. 409를 주고 새로고침을 안내합니다. rulesHash 는 readRows·previewRows·locale·telemetry 를 빼고 계산하므로, 브라우저에서 이 옵션을 더해도 값이 같습니다.
  • 다시 검사. validate(body.rows, rules) 는 브라우저 check() 와 같은 코드로 같은 결과를 냅니다. 행 안의 $row(엑셀 행 번호)를 그대로 쓰므로 오류 위치가 사용자가 본 것과 같습니다. 정리된 값을 다시 넣어도 결과가 같습니다(멱등). 실패하면 makeErrorReport(result, 'csv') 로 만든 CSV(UTF-8 BOM, 개인정보 마스킹)를 422로 돌려줍니다. 칸 문제에는 header(규칙의 머리글)가 붙습니다.
  • 행 수와 failOn. validate() 는 rows.min/rows.max(결과 행 수, ROWS_TOO_FEW/ROWS_TOO_MANY)와 failOn 을 브라우저 check() 와 똑같이 적용하므로 422 CSV에 함께 나옵니다. 예제는 그 전에 rows.max 보다 긴 배열을 413으로 바로 거절해 일을 줄입니다.
  • 저장은 result.rows 로. 받은 body.rows 가 아니라 validate() 가 돌려준 정리된 행을 저장합니다. 규칙에 없는 키는 빠져 있습니다. result.rows 는 마스킹하지 않습니다.
  • 파일을 함께 받기(/api/orders-with-file). <excel-82 upload-file="include"> 는 multipart로 file, data(위 JSON에서 report 를 뺀 문자열), report 를 보냅니다. report.file.sha256 과 받은 파일의 await sha256(bytes) 를 비교해 브라우저가 검사한 그 파일인지 확인합니다. multer·busboy를 써도 됩니다.
  • JS 행 훅. 브라우저에서 validate(…, { rowRules: [fn] }) 훅을 썼다면 서버에서도 같은 훅을 넘겨야 합니다. 훅은 규칙 JSON에 없습니다.

서버 보안 설정

요청 본문은 누구나 만들 수 있으므로, 2차 검수 경로는 공격 입력을 받는다고 보고 다음을 지키세요.

  • 본문 크기 제한. express.json({ limit })(multipart라면 express.raw({ limit }) 나 multer의 limits)을 예상하는 가장 큰 업로드에 맞춥니다. 행 수 × 열 수 × 칸 평균 길이로 어림합니다.
  • 행 수 상한. 규칙에 rows.max 를 두고, validate() 전에 rows.length 를 비교해 넘으면 바로 거절합니다(위 예제의 413). validate() 도 ROWS_TOO_MANY 를 내지만 모든 행을 읽은 뒤입니다.
  • 칸 길이 상한. 열의 maxLength(기본 32,767, 엑셀 한 칸의 최대)를 넘는 값은 CELL_LENGTH 이고, 정규식(pattern)·개인정보 감지·숫자 읽기를 하지 않습니다. 이름·코드처럼 짧은 열은 maxLength 를 작게 잡으세요.
  • validate() 는 선형 시간. 0.2.0의 개인정보 감지와 형식 검사는 입력 크기에 비례하는 시간에 끝납니다. 느려질 수 있는 것은 직접 넣는 것뿐입니다. pattern 에 중첩된 반복((a+)+)을 쓰지 말고, rowRules JS 훅도 행마다 가볍게 유지하세요.
  • 리포트는 힌트. 결정은 늘 validate() 결과로 합니다. 인증·요청 횟수 제한은 다른 API와 똑같이 둡니다.

실행해 보기

try.mjs 는 브라우저 역할을 합니다. Node에서 CSV를 check() 하고, <excel-82> 가 보내는 것과 같은 본문을 서버로 보냅니다.

// try.mjs — plays the browser: check() a CSV in Node, then send what <excel-82> sends.
import { check } from '82excel';

process.env.PORT = '0';
const { default: server } = await import('./server.mjs');
if (!server.listening) await new Promise((r) => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;
const rules = (await import('./rules.json', { with: { type: 'json' } })).default;

const csv = '주문번호,주문일,전화번호,금액\r\nAB000001,2024.1.5,010 1234 5678,"₩12,000"\r\nAB000002,2024-01-06,+82 10-9876-5432,500원\r\n';
const file = new File([csv], 'orders.csv', { type: 'text/csv' });
const r = await check(file, { ...rules, readRows: true });
console.log('browser check:', r.ok, JSON.stringify(r.rows));

// The JSON body <excel-82> builds for v2 rules (spec §5 P-1).
const data = {
  fileName: file.name,
  sheet: r.info.sheets[0].name,
  schema: { id: r.report.schemaId, version: r.report.schemaVersion, hash: r.report.schemaHash },
  columns: r.columns.map((c) => c.key),
  rows: r.rows,
};
const post = (path, body, type) => fetch(base + path, { method: 'POST', body, headers: type ? { 'content-type': type } : {} });
const json = (path, body) => post(path, JSON.stringify(body), 'application/json');
const show = async (label, res) => console.log(`${label}: ${res.status}\n${(await res.text()).replace(/^/, '').trimEnd()}`);

await show('1 valid', await json('/api/orders', { ...data, report: r.report }));

// Someone skips the browser and posts rows directly.
const bad = { ...data, rows: [{ $row: 2, orderNo: 'X1', date: '2024-02-30', phone: '010-123-4567', amount: -5 }] };
await show('2 tampered', await json('/api/orders', bad));

await show('3 old rules', await json('/api/orders', { ...data, schema: { ...data.schema, hash: 'sha256:0' } }));
await show('4 not rows', await json('/api/orders', { ...data, rows: 'x' }));

const form = (f) => {
  const fd = new FormData();
  fd.append('file', f);
  fd.append('data', JSON.stringify(data));
  fd.append('report', JSON.stringify(r.report));
  return fd;
};
await show('5 include file', await post('/api/orders-with-file', form(file)));
await show('6 other file', await post('/api/orders-with-file', form(new File(['주문번호\r\nZZ999999\r\n'], 'orders.csv'))));

// rows.min is 1: an empty upload fails as it would in the browser.
await show('7 no rows', await json('/api/orders', { ...data, rows: [] }));

const broken = form(file);
broken.set('data', '{not json');
await show('8 malformed data', await post('/api/orders-with-file', broken));

server.close();
npm i 82excel express
node try.mjs

출력(82excel 0.2.0, express 5.2.1, Node 24; 포트는 매번 다름):

listening on 4211
browser check: true [{"$row":2,"orderNo":"AB000001","date":"2024-01-05","phone":"010-1234-5678","amount":12000},{"$row":3,"orderNo":"AB000002","date":"2024-01-06","phone":"010-9876-5432","amount":500}]
1 valid: 200
{"saved":2,"first":{"$row":2,"orderNo":"AB000001","date":"2024-01-05","phone":"010-1234-5678","amount":12000}}
2 tampered: 422
Row,Column,Header,Original value,Message,Code,Severity
2,,주문번호,X1,Row 2 (주문번호): Does not match the required pattern,CELL_PATTERN,error
2,,주문일,2024-02-30,Row 2 (주문일): Date does not exist,CELL_DATE,error
2,,전화번호,010-***-4567,Row 2 (전화번호): Invalid mobile phone number,KR_PHONE,error
2,,금액,-5,Row 2 (금액): Must be at least 0,CELL_RANGE,error
3 old rules: 409
{"error":"rules changed; reload the page"}
4 not rows: 400
{"error":"82excel: rows must be an array of row objects"}
5 include file: 200
{"saved":2,"file":"orders.csv","bytes":134}
6 other file: 400
{"error":"file does not match the report"}
7 no rows: 422
Row,Column,Header,Original value,Message,Code,Severity
1,,,,"Expected at least 1 rows, found 0",ROWS_TOO_FEW,error
8 malformed data: 400
{"error":"malformed multipart body or JSON field"}
  • 1: 브라우저에서 통과한 행은 서버에서도 통과합니다. 2024.1.5 → 2024-01-05, ₩12,000 → 12000, +82 10-9876-5432 → 010-9876-5432.
  • 2: 브라우저를 거치지 않고 만든 요청도 validate() 가 잡습니다. 리포트의 전화번호는 010-***-4567 로 가려져 있고, 머리글 칸은 규칙의 header 입니다.
  • 3: 다른 규칙으로 검사한 페이지에서 온 요청은 409입니다.
  • 5·6: 파일을 함께 받으면 sha256으로 브라우저가 검사한 파일인지 확인합니다.
  • 7: rows.min 도 validate() 가 검사합니다(ROWS_TOO_FEW, 머리글 행 1에, 열 없이).
  • 8: multipart의 JSON 칸이 깨져 있으면 500이 아니라 400입니다.

다른 언어

Java, PHP, Python, C#, Go 서버 SDK는 준비 중입니다. 그때까지는 Node 서비스로 validate() 를 돌리거나, 적합성 테스트를 기준으로 직접 옮기세요.