Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'fix: batch 4 — date validation, CEILING/FLOOR, GCD/LCM, rate limiting' (#245) from fix/batch4-dates-ratelimit into main

scott 58028cbf 619e8814

+132 -13
+28 -1
server/index.ts
··· 331 331 return typeof id === 'string' && id.length > 0 && id.length <= 100 && /^[a-zA-Z0-9_-]+$/.test(id); 332 332 } 333 333 334 + /** Simple in-memory rate limiter per key (user or IP) */ 335 + const rateLimitMap = new Map<string, { count: number; resetAt: number }>(); 336 + function rateLimit(key: string, maxPerWindow: number, windowMs: number): boolean { 337 + const now = Date.now(); 338 + const entry = rateLimitMap.get(key); 339 + if (!entry || now >= entry.resetAt) { 340 + rateLimitMap.set(key, { count: 1, resetAt: now + windowMs }); 341 + return true; 342 + } 343 + if (entry.count >= maxPerWindow) return false; 344 + entry.count++; 345 + return true; 346 + } 347 + // Periodically clean up expired entries (every 60s) 348 + setInterval(() => { 349 + const now = Date.now(); 350 + for (const [key, entry] of rateLimitMap) { 351 + if (now >= entry.resetAt) rateLimitMap.delete(key); 352 + } 353 + }, 60000).unref(); 354 + 334 355 // --- Express --- 335 356 const app = express(); 336 357 app.use(compression()); ··· 499 520 }); 500 521 501 522 // Accept both PUT (normal save) and POST (sendBeacon — which can only POST) 502 - const snapshotHandler = (req: Request<{ id: string }>, res: Response): void => { 523 + const snapshotHandler = (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response): void => { 503 524 if (!isValidDocId(req.params.id)) { 504 525 res.status(400).json({ error: 'Invalid document ID' }); 526 + return; 527 + } 528 + // Rate limit: 60 snapshot writes per minute per user 529 + const rlKey = `snap:${req.tsUser?.login || 'anon'}`; 530 + if (!rateLimit(rlKey, 60, 60000)) { 531 + res.status(429).json({ error: 'Too many snapshot writes, please slow down' }); 505 532 return; 506 533 } 507 534 if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) {
+21 -12
src/sheets/formulas.ts
··· 847 847 case 'NOW': return new Date(); 848 848 case 'TODAY': { const d = new Date(); d.setHours(0, 0, 0, 0); return d; } 849 849 case 'DATE': return new Date(toNum(args[0]), toNum(args[1]) - 1, toNum(args[2])); 850 - case 'YEAR': return new Date(args[0]).getFullYear(); 851 - case 'MONTH': return new Date(args[0]).getMonth() + 1; 852 - case 'DAY': return new Date(args[0]).getDate(); 850 + case 'YEAR': { const yd = new Date(args[0] as string | number | Date); return isNaN(yd.getTime()) ? '#VALUE!' : yd.getFullYear(); } 851 + case 'MONTH': { const md = new Date(args[0] as string | number | Date); return isNaN(md.getTime()) ? '#VALUE!' : md.getMonth() + 1; } 852 + case 'DAY': { const dd = new Date(args[0] as string | number | Date); return isNaN(dd.getTime()) ? '#VALUE!' : dd.getDate(); } 853 853 854 854 case 'VLOOKUP': { 855 855 const needle = args[0]; ··· 1191 1191 const cNum = toNum(args[0]); 1192 1192 const cSig = toNum(args[1]); 1193 1193 if (cSig === 0) return 0; 1194 + if (cNum > 0 && cSig < 0) return '#NUM!'; 1194 1195 return Math.ceil(cNum / cSig) * cSig; 1195 1196 } 1196 1197 case 'FLOOR': { 1197 1198 const fNum = toNum(args[0]); 1198 1199 const fSig = toNum(args[1]); 1199 1200 if (fSig === 0) return 0; 1201 + if (fNum > 0 && fSig < 0) return '#NUM!'; 1200 1202 return Math.floor(fNum / fSig) * fSig; 1201 1203 } 1202 1204 case 'FACT': { ··· 1220 1222 return Math.round(cResult); 1221 1223 } 1222 1224 case 'GCD': { 1223 - let ga = Math.abs(Math.floor(toNum(args[0]))); 1224 - let gb = Math.abs(Math.floor(toNum(args[1]))); 1225 - while (gb) { [ga, gb] = [gb, ga % gb]; } 1225 + const gcdVals = flat(args).map(v => Math.abs(Math.floor(toNum(v)))); 1226 + let ga = gcdVals[0] ?? 0; 1227 + for (let gi = 1; gi < gcdVals.length; gi++) { 1228 + let gb = gcdVals[gi]; 1229 + while (gb) { [ga, gb] = [gb, ga % gb]; } 1230 + } 1226 1231 return ga; 1227 1232 } 1228 1233 case 'LCM': { 1229 - const la = Math.abs(Math.floor(toNum(args[0]))); 1230 - const lb = Math.abs(Math.floor(toNum(args[1]))); 1231 - if (la === 0 && lb === 0) return 0; 1232 - let lg = la, lt = lb; 1233 - while (lt) { [lg, lt] = [lt, lg % lt]; } 1234 - return (la / lg) * lb; 1234 + const lcmVals = flat(args).map(v => Math.abs(Math.floor(toNum(v)))); 1235 + let result = lcmVals[0] ?? 0; 1236 + for (let li = 1; li < lcmVals.length; li++) { 1237 + const b = lcmVals[li]; 1238 + if (result === 0 && b === 0) continue; 1239 + let lg = result, lt = b; 1240 + while (lt) { [lg, lt] = [lt, lg % lt]; } 1241 + result = (result / lg) * b; 1242 + } 1243 + return result; 1235 1244 } 1236 1245 case 'QUOTIENT': { 1237 1246 const qd = toNum(args[1]);
+83
tests/formulas-edge-cases.test.ts
··· 1303 1303 expect(evalWith('ROUNDDOWN(-3.14159, 2)')).toBe(-3.14); 1304 1304 }); 1305 1305 }); 1306 + 1307 + // ============================================================ 1308 + // YEAR/MONTH/DAY date validation (#374) 1309 + // ============================================================ 1310 + 1311 + describe('YEAR/MONTH/DAY — date validation', () => { 1312 + it('YEAR with invalid date returns #VALUE!', () => { 1313 + expect(evalWith('YEAR("not-a-date")')).toBe('#VALUE!'); 1314 + }); 1315 + 1316 + it('MONTH with invalid date returns #VALUE!', () => { 1317 + expect(evalWith('MONTH("not-a-date")')).toBe('#VALUE!'); 1318 + }); 1319 + 1320 + it('DAY with invalid date returns #VALUE!', () => { 1321 + expect(evalWith('DAY("not-a-date")')).toBe('#VALUE!'); 1322 + }); 1323 + 1324 + it('YEAR with valid date works', () => { 1325 + expect(evalWith('YEAR("2024-06-15")')).toBe(2024); 1326 + }); 1327 + 1328 + it('MONTH with valid date works', () => { 1329 + expect(evalWith('MONTH("2024-06-15")')).toBe(6); 1330 + }); 1331 + 1332 + it('DAY with valid date works', () => { 1333 + // Use full datetime to avoid timezone offset issues 1334 + expect(evalWith('DAY("2024-06-15T12:00:00")')).toBe(15); 1335 + }); 1336 + }); 1337 + 1338 + // ============================================================ 1339 + // CEILING/FLOOR negative significance (#376) 1340 + // ============================================================ 1341 + 1342 + describe('CEILING/FLOOR — negative significance', () => { 1343 + it('CEILING with positive number and negative significance returns #NUM!', () => { 1344 + expect(evalWith('CEILING(4.5, -2)')).toBe('#NUM!'); 1345 + }); 1346 + 1347 + it('CEILING with negative number and negative significance works', () => { 1348 + expect(evalWith('CEILING(-4.5, -2)')).toBe(-6); 1349 + }); 1350 + 1351 + it('FLOOR with positive number and negative significance returns #NUM!', () => { 1352 + expect(evalWith('FLOOR(4.5, -2)')).toBe('#NUM!'); 1353 + }); 1354 + 1355 + it('FLOOR with negative number and negative significance works', () => { 1356 + expect(evalWith('FLOOR(-4.5, -2)')).toBe(-4); 1357 + }); 1358 + }); 1359 + 1360 + // ============================================================ 1361 + // GCD/LCM variadic (#397) 1362 + // ============================================================ 1363 + 1364 + describe('GCD/LCM — variadic support', () => { 1365 + it('GCD(12, 18, 24) returns 6', () => { 1366 + expect(evalWith('GCD(12, 18, 24)')).toBe(6); 1367 + }); 1368 + 1369 + it('GCD(5, 10, 15, 20) returns 5', () => { 1370 + expect(evalWith('GCD(5, 10, 15, 20)')).toBe(5); 1371 + }); 1372 + 1373 + it('LCM(4, 6, 8) returns 24', () => { 1374 + expect(evalWith('LCM(4, 6, 8)')).toBe(24); 1375 + }); 1376 + 1377 + it('LCM(3, 5, 7) returns 105', () => { 1378 + expect(evalWith('LCM(3, 5, 7)')).toBe(105); 1379 + }); 1380 + 1381 + it('GCD with 2 args still works', () => { 1382 + expect(evalWith('GCD(12, 8)')).toBe(4); 1383 + }); 1384 + 1385 + it('LCM with 2 args still works', () => { 1386 + expect(evalWith('LCM(4, 6)')).toBe(12); 1387 + }); 1388 + });