문서 › 함수와 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+)+)을 쓰지 말고,rowRulesJS 훅도 행마다 가볍게 유지하세요.- 리포트는 힌트. 결정은 늘
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() 를 돌리거나, 적합성 테스트를 기준으로 직접 옮기세요.