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

Configure Feed

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

fix: doc ID param validation, clearInterval safety, parseHash, TEXT formats

- Add app.param('id') middleware to validate document IDs on all
routes, replacing per-handler checks (#398)
- Fix clearInterval null assertion — guard with conditional (#392)
- Validate parseHash() output: require alphanumeric docId and
base64url keyString (#390)
- Expand TEXT() format support: arbitrary decimal precision,
scientific notation, generic percentage/comma formats (#401)

+81 -18
+10 -4
server/index.ts
··· 388 388 next(); 389 389 }); 390 390 391 + // Validate :id param on all routes that use it 392 + app.param('id', (req: Request, res: Response, next, id: string) => { 393 + if (!isValidDocId(id)) { 394 + res.status(400).json({ error: 'Invalid document ID' }); 395 + return; 396 + } 397 + next(); 398 + }); 399 + 391 400 // API routes 392 401 393 402 // Current user identity (from Tailscale headers or anonymous) ··· 521 530 522 531 // Accept both PUT (normal save) and POST (sendBeacon — which can only POST) 523 532 const snapshotHandler = (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response): void => { 524 - if (!isValidDocId(req.params.id)) { 525 - res.status(400).json({ error: 'Invalid document ID' }); 526 - return; 527 - } 533 + // Note: :id is already validated by app.param('id') middleware 528 534 // Rate limit: 60 snapshot writes per minute per user 529 535 const rlKey = `snap:${req.tsUser?.login || 'anon'}`; 530 536 if (!rateLimit(rlKey, 60, 60000)) {
+6 -4
src/lib/crypto.ts
··· 113 113 if (!hash) return null; 114 114 const slash = hash.indexOf('/'); 115 115 if (slash === -1) return null; 116 - return { 117 - docId: hash.slice(0, slash), 118 - keyString: hash.slice(slash + 1), 119 - }; 116 + const docId = hash.slice(0, slash); 117 + const keyString = hash.slice(slash + 1); 118 + // Validate: docId must be alphanumeric/dash/underscore, keyString must be non-empty base64url 119 + if (!docId || !/^[a-zA-Z0-9_-]+$/.test(docId)) return null; 120 + if (!keyString || !/^[A-Za-z0-9_-]+$/.test(keyString)) return null; 121 + return { docId, keyString }; 120 122 } 121 123 122 124 /**
+3 -3
src/lib/provider.ts
··· 220 220 this.connected = false; 221 221 this.synced = false; 222 222 this._emit('status', { connected: false }); 223 - clearInterval(this._snapshotTimer!); 223 + if (this._snapshotTimer) clearInterval(this._snapshotTimer); 224 224 225 225 // Reconnect with exponential backoff (1s, 2s, 4s, 8s... capped at 30s) 226 226 if (!this._destroyed) { ··· 271 271 this.synced = true; 272 272 this._emit('sync', true); 273 273 // Start periodic snapshot saves now that we have a complete document state 274 - clearInterval(this._snapshotTimer!); 274 + if (this._snapshotTimer) clearInterval(this._snapshotTimer); 275 275 this._snapshotTimer = setInterval(() => this._saveSnapshot(), SNAPSHOT_INTERVAL); 276 276 // If there were unsaved changes (e.g., edits made while disconnected), save now 277 277 if (this._hasUnsavedChanges) { ··· 598 598 this.ws.close(); 599 599 this.ws = null; 600 600 } 601 - clearInterval(this._snapshotTimer!); 601 + if (this._snapshotTimer) clearInterval(this._snapshotTimer); 602 602 this.connected = false; 603 603 } 604 604
+34 -7
src/sheets/formulas.ts
··· 1766 1766 } 1767 1767 1768 1768 function formatValue(num: number, fmt: string): string { 1769 - // Simplified TEXT() formatting 1770 - if (fmt === '0') return Math.round(num).toString(); 1771 - if (fmt === '0.00') return num.toFixed(2); 1772 - if (fmt === '#,##0') return num.toLocaleString(undefined, { maximumFractionDigits: 0 }); 1773 - if (fmt === '#,##0.00') return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 1774 - if (fmt === '0%') return (num * 100).toFixed(0) + '%'; 1775 - if (fmt === '0.00%') return (num * 100).toFixed(2) + '%'; 1769 + // Percentage formats 1770 + if (fmt.endsWith('%')) { 1771 + const inner = fmt.slice(0, -1); 1772 + const dotPos = inner.indexOf('.'); 1773 + const decimals = dotPos === -1 ? 0 : inner.length - dotPos - 1; 1774 + return (num * 100).toFixed(decimals) + '%'; 1775 + } 1776 + 1777 + // Scientific notation: 0.00E+00 (check before dot/comma to avoid false match) 1778 + if (/e\+/i.test(fmt)) { 1779 + const dotE = fmt.toLowerCase().indexOf('e'); 1780 + const decPart = fmt.slice(0, dotE); 1781 + const decDot = decPart.indexOf('.'); 1782 + const decimals = decDot === -1 ? 0 : decPart.length - decDot - 1; 1783 + return num.toExponential(decimals).toUpperCase(); 1784 + } 1785 + 1786 + // Comma-separated with optional decimals: #,##0 or #,##0.00 etc. 1787 + if (fmt.includes(',')) { 1788 + const dotPos = fmt.indexOf('.'); 1789 + const decimals = dotPos === -1 ? 0 : fmt.length - dotPos - 1; 1790 + return num.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); 1791 + } 1792 + 1793 + // Fixed-point: 0.0, 0.00, 0.000, etc. 1794 + const dotPos = fmt.indexOf('.'); 1795 + if (dotPos !== -1) { 1796 + const decimals = fmt.length - dotPos - 1; 1797 + return num.toFixed(decimals); 1798 + } 1799 + 1800 + // Plain integer format 1801 + if (fmt === '0' || fmt === '#') return Math.round(num).toString(); 1802 + 1776 1803 return num.toString(); 1777 1804 } 1778 1805
+28
tests/formulas-extended.test.ts
··· 186 186 it('formats with "0.00%" format', () => { 187 187 expect(evalWith('TEXT(0.1234,"0.00%")')).toBe('12.34%'); 188 188 }); 189 + 190 + it('formats with "0.0" (1 decimal)', () => { 191 + expect(evalWith('TEXT(3.14159,"0.0")')).toBe('3.1'); 192 + }); 193 + 194 + it('formats with "0.000" (3 decimals)', () => { 195 + expect(evalWith('TEXT(3.14159,"0.000")')).toBe('3.142'); 196 + }); 197 + 198 + it('formats with "#,##0.00" (comma + 2 decimals)', () => { 199 + const result = evalWith('TEXT(1234567.89,"#,##0.00")'); 200 + expect(result).toContain('234'); 201 + expect(result).toContain('567'); 202 + expect(result).toContain('89'); 203 + }); 204 + 205 + it('formats with "#" (plain integer)', () => { 206 + expect(evalWith('TEXT(7.9,"#")')).toBe('8'); 207 + }); 208 + 209 + it('formats with "0.0%" (1 decimal percent)', () => { 210 + expect(evalWith('TEXT(0.1234,"0.0%")')).toBe('12.3%'); 211 + }); 212 + 213 + it('formats with scientific "0.00E+00"', () => { 214 + const result = evalWith('TEXT(12345,"0.00E+00")'); 215 + expect(result).toMatch(/1\.23E\+4/i); 216 + }); 189 217 }); 190 218 191 219 describe('FIND/SEARCH functions', () => {