Skip to content

Commit 4f254c7

Browse files
dani-polaniclaude
andcommitted
fix: API size limits, decompression guard, lint CI step
- Add per-line (10k chars) and total (80k chars) text size limits to /api/align POST and GET - Add 2 MB decompression bomb guard in inflateBase64url (codec.ts) - Derive palette validation from PALETTES constant instead of duplicating the set - Add typecheck (npm run check) and lint (npm run lint) steps to CI - Run prettier --write across all files to fix pre-existing formatting - Fix all 12 pre-existing ESLint errors: unused import, useless mustaches, missing each-block keys, bare expression (void tipPortalEl), preserve-caught-error - Configure svelte/no-navigation-without-resolve with ignoreLinks: true Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f0d50ef commit 4f254c7

42 files changed

Lines changed: 308 additions & 262 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,8 @@ jobs:
2424

2525
- run: npm run audit:ci
2626

27+
- run: npm run check
28+
29+
- run: npm run lint
30+
2731
- run: npm test

bitext/eslint.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export default defineConfig(
3636
{
3737
// Override or add rule settings here, such as:
3838
// 'svelte/button-has-type': 'error'
39-
rules: {}
39+
rules: {
40+
// <a href> tags don't need resolve() — only goto()/pushState()/replaceState() do.
41+
'svelte/no-navigation-without-resolve': ['error', { ignoreLinks: true }]
42+
}
4043
}
4144
);

bitext/scripts/README.md

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,17 @@ The script starts `vite preview` on port **4173** (override with `PREVIEW_PORT`)
3535

3636
## Environment variables
3737

38-
| Variable | Required | Description |
39-
|----------|----------|-------------|
40-
| `OBJECT_STORAGE_ACCESS_KEY` | yes | S3-compatible access key |
41-
| `OBJECT_STORAGE_SECRET_KEY` | yes | Secret key |
42-
| `OBJECT_STORAGE_BUCKET_NAME` | yes | Bucket name |
43-
| `OBJECT_STORAGE_ORIGIN_ENDPOINT` | yes | S3 API origin (upload only), e.g. `fra1.digitaloceanspaces.com` |
44-
| `OBJECT_STORAGE_CDN_ENDPOINT` | yes* | CDN host for **site** `<img>` / OG URLs, e.g. `fra1.cdn.digitaloceanspaces.com` |
45-
| `OBJECT_STORAGE_PREFIX` | no | Object key prefix, default `examples` |
46-
| `OBJECT_STORAGE_PUBLIC_BASE` | no | Override full public CDN base URL (no trailing slash) |
47-
| `PREVIEW_URL` | no | If set, skip starting `vite preview` (use existing server) |
48-
| `PREVIEW_PORT` | no | Default `4173` when script starts preview |
38+
| Variable | Required | Description |
39+
| -------------------------------- | -------- | ------------------------------------------------------------------------------- |
40+
| `OBJECT_STORAGE_ACCESS_KEY` | yes | S3-compatible access key |
41+
| `OBJECT_STORAGE_SECRET_KEY` | yes | Secret key |
42+
| `OBJECT_STORAGE_BUCKET_NAME` | yes | Bucket name |
43+
| `OBJECT_STORAGE_ORIGIN_ENDPOINT` | yes | S3 API origin (upload only), e.g. `fra1.digitaloceanspaces.com` |
44+
| `OBJECT_STORAGE_CDN_ENDPOINT` | yes\* | CDN host for **site** `<img>` / OG URLs, e.g. `fra1.cdn.digitaloceanspaces.com` |
45+
| `OBJECT_STORAGE_PREFIX` | no | Object key prefix, default `examples` |
46+
| `OBJECT_STORAGE_PUBLIC_BASE` | no | Override full public CDN base URL (no trailing slash) |
47+
| `PREVIEW_URL` | no | If set, skip starting `vite preview` (use existing server) |
48+
| `PREVIEW_PORT` | no | Default `4173` when script starts preview |
4949

5050
\* Not required if `OBJECT_STORAGE_PUBLIC_BASE` is set.
5151

@@ -55,12 +55,12 @@ Upload sets **`ACL: public-read`** on each object (per-file, not bucket-wide). T
5555

5656
Objects use `Cache-Control: immutable`, so the CDN keeps old PNGs until purged. After upload, the script can call the [DigitalOcean CDN purge API](https://docs.digitalocean.com/products/spaces/how-to/manage-cdn-cache/) when a **Personal Access Token** is set (this is **not** the Spaces S3 access key):
5757

58-
| Variable | Required | Description |
59-
|----------|----------|-------------|
60-
| `DIGITALOCEAN_API_TOKEN` or `DO_API_TOKEN` | for purge | DO control-plane PAT |
61-
| `DO_CDN_ENDPOINT_ID` | no | CDN endpoint UUID; auto-resolved from bucket + origin if omitted |
62-
| `OBJECT_STORAGE_CDN_PURGE` | no | Set to `0` to skip purge |
63-
| `OBJECT_STORAGE_CDN_PURGE_PATHS` | no | Comma-separated paths, default `examples/*` |
58+
| Variable | Required | Description |
59+
| ------------------------------------------ | --------- | ---------------------------------------------------------------- |
60+
| `DIGITALOCEAN_API_TOKEN` or `DO_API_TOKEN` | for purge | DO control-plane PAT |
61+
| `DO_CDN_ENDPOINT_ID` | no | CDN endpoint UUID; auto-resolved from bucket + origin if omitted |
62+
| `OBJECT_STORAGE_CDN_PURGE` | no | Set to `0` to skip purge |
63+
| `OBJECT_STORAGE_CDN_PURGE_PATHS` | no | Comma-separated paths, default `examples/*` |
6464

6565
Without a token, upload still succeeds; purge manually in the DO control panel as today.
6666

bitext/scripts/purge-do-cdn.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,6 @@ export async function purgeDigitalOceanCdnCache(
8181

8282
export function digitalOceanApiTokenFromEnv(): string | undefined {
8383
return (
84-
process.env.DIGITALOCEAN_API_TOKEN?.trim() ||
85-
process.env.DO_API_TOKEN?.trim() ||
86-
undefined
84+
process.env.DIGITALOCEAN_API_TOKEN?.trim() || process.env.DO_API_TOKEN?.trim() || undefined
8785
);
8886
}

bitext/scripts/render-example-previews.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,7 @@ function startPreview(): ChildProcess {
3838
// detached → own process group so we can SIGTERM the whole tree on exit
3939
return spawn(
4040
'npm',
41-
[
42-
'run',
43-
'preview',
44-
'--',
45-
'--port',
46-
String(PREVIEW_PORT),
47-
'--host',
48-
'127.0.0.1',
49-
'--strictPort'
50-
],
41+
['run', 'preview', '--', '--port', String(PREVIEW_PORT), '--host', '127.0.0.1', '--strictPort'],
5142
{
5243
cwd: BITEXT_ROOT,
5344
stdio: ['ignore', 'pipe', 'pipe'],
@@ -85,7 +76,11 @@ async function stopPreview(proc: ChildProcess, port: number): Promise<void> {
8576
}
8677

8778
/** Wait until Vite prints its Local URL — fetch alone is not enough (stale/zombie listeners). */
88-
function waitForPreviewProcess(proc: ChildProcess, url: string, timeoutMs = 120_000): Promise<void> {
79+
function waitForPreviewProcess(
80+
proc: ChildProcess,
81+
url: string,
82+
timeoutMs = 120_000
83+
): Promise<void> {
8984
return new Promise((resolve, reject) => {
9085
const timer = setTimeout(
9186
() => reject(new Error(`Preview process did not become ready within ${timeoutMs}ms`)),
@@ -209,7 +204,8 @@ async function main(): Promise<void> {
209204
() => document.documentElement.dataset.exampleRenderReady ?? '(unset)'
210205
);
211206
throw new Error(
212-
`Layout not ready for ${slug} (title=${JSON.stringify(title)}, ready=${ready}): ${err}`
207+
`Layout not ready for ${slug} (title=${JSON.stringify(title)}, ready=${ready})`,
208+
{ cause: err }
213209
);
214210
}
215211
const target = page.locator('[data-example-render-target] .preview-frame');

bitext/scripts/upload-example-previews.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,9 @@ async function main(): Promise<void> {
128128
const cdnId =
129129
process.env.DO_CDN_ENDPOINT_ID?.trim() ||
130130
(await resolveDigitalOceanCdnEndpointId(doToken, bucket, endpoint));
131-
const purgePaths =
132-
process.env.OBJECT_STORAGE_CDN_PURGE_PATHS?.trim().split(/\s*,\s*/).filter(Boolean) ??
133-
[`${prefix}/*`];
131+
const purgePaths = process.env.OBJECT_STORAGE_CDN_PURGE_PATHS?.trim()
132+
.split(/\s*,\s*/)
133+
.filter(Boolean) ?? [`${prefix}/*`];
134134
console.log('');
135135
console.log(`Purging CDN cache (endpoint ${cdnId}) …`);
136136
console.log(` paths: ${purgePaths.join(', ')}`);

bitext/src/lib/analytics/affiliate-link-tracking.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@ function onDocumentClickCapture(e: MouseEvent): void {
1515
const g = (window as Window & { gtag?: (...args: unknown[]) => void }).gtag;
1616
if (typeof g !== 'function') return;
1717

18-
const linkText = (el.textContent ?? '')
19-
.trim()
20-
.replace(/\s+/g, ' ')
21-
.slice(0, 120);
18+
const linkText = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 120);
2219

2320
g('event', EVENT_NAME, {
2421
partner: el.dataset.partner ?? '',

bitext/src/lib/api/align.test.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,16 @@ describe('parseAlignBody', () => {
2525
});
2626

2727
it('rejects more than 8 lines', () => {
28-
expect(parseAlignBody({ lines: Array(9).fill('x') })).toMatchObject({ err: expect.any(String) });
28+
expect(parseAlignBody({ lines: Array(9).fill('x') })).toMatchObject({
29+
err: expect.any(String)
30+
});
2931
});
3032

3133
it('accepts string lines without alignments (backward compat)', () => {
3234
const result = parseAlignBody({ lines: ['Hello', 'Bonjour'] });
33-
expect(result).toMatchObject({ ok: { lines: [{ text: 'Hello' }, { text: 'Bonjour' }], alignments: [] } });
35+
expect(result).toMatchObject({
36+
ok: { lines: [{ text: 'Hello' }, { text: 'Bonjour' }], alignments: [] }
37+
});
3438
});
3539

3640
it('accepts string lines with alignments', () => {
@@ -48,7 +52,9 @@ describe('parseAlignBody', () => {
4852
});
4953

5054
it('rejects object line missing text', () => {
51-
expect(parseAlignBody({ lines: [{ font: 'Noto Serif' }] })).toMatchObject({ err: expect.any(String) });
55+
expect(parseAlignBody({ lines: [{ font: 'Noto Serif' }] })).toMatchObject({
56+
err: expect.any(String)
57+
});
5258
});
5359

5460
it('rejects alignment tuples that are not length-4 integer arrays', () => {
@@ -204,9 +210,9 @@ describe('buildAlignUrl', () => {
204210
});
205211

206212
it('rejects invalid tokenMergeChar (more than one character)', () => {
207-
expect(
208-
parseAlignBody({ lines: ['a', 'b'], settings: { tokenMergeChar: '++' } })
209-
).toMatchObject({ err: expect.stringContaining('tokenMergeChar') });
213+
expect(parseAlignBody({ lines: ['a', 'b'], settings: { tokenMergeChar: '++' } })).toMatchObject(
214+
{ err: expect.stringContaining('tokenMergeChar') }
215+
);
210216
});
211217

212218
it('applies per-line options (font, sizePx, rtl)', () => {
@@ -252,9 +258,9 @@ describe('buildAlignUrl', () => {
252258
});
253259

254260
it('rejects line index out of range', () => {
255-
expect(
256-
buildAlignUrl(ORIGIN, { lines: ['a', 'b'], alignments: [[0, 0, 5, 0]] })
257-
).toMatchObject({ err: expect.stringContaining('lineB=5') });
261+
expect(buildAlignUrl(ORIGIN, { lines: ['a', 'b'], alignments: [[0, 0, 5, 0]] })).toMatchObject({
262+
err: expect.stringContaining('lineB=5')
263+
});
258264
});
259265

260266
it('rejects non-adjacent lines', () => {

bitext/src/lib/api/align.ts

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { encodeState } from '$lib/serialization/encode.js';
22
import { tokenize, tokenizeOptionsFromVisualSettings } from '$lib/domain/tokens.js';
3-
import { createConnectionId, pendingAlignmentColor, type Connection } from '$lib/domain/alignment.js';
3+
import {
4+
createConnectionId,
5+
pendingAlignmentColor,
6+
type Connection
7+
} from '$lib/domain/alignment.js';
48
import {
59
defaultVisualSettingsV2,
610
SCHEMA_VERSION,
@@ -10,10 +14,12 @@ import {
1014
type PairControlV2,
1115
type VisualSettingsV2
1216
} from '$lib/serialization/schema.js';
17+
import { PALETTES } from '$lib/domain/palettes.js';
1318

1419
const DEFAULT_FONT_FAMILY = 'Inter';
1520
const DEFAULT_TEXT_SIZE_PX = 36;
1621
const DEFAULT_WORD_GAP_PX = 14;
22+
const MAX_LINE_TEXT_LENGTH = 10_000;
1723

1824
export type AlignmentTuple = [number, number, number, number];
1925

@@ -84,11 +90,21 @@ export type AlignResult = { url: string } | { err: string };
8490
// ── Validation helpers ────────────────────────────────────────────────────────
8591

8692
function parseLineEntry(val: unknown, idx: number): LineInput | { err: string } {
87-
if (typeof val === 'string') return { text: val };
93+
if (typeof val === 'string') {
94+
if (val.length > MAX_LINE_TEXT_LENGTH)
95+
return {
96+
err: `lines[${idx}] text exceeds maximum length of ${MAX_LINE_TEXT_LENGTH} characters`
97+
};
98+
return { text: val };
99+
}
88100
if (!val || typeof val !== 'object') return { err: `lines[${idx}] must be a string or object` };
89101
const v = val as Record<string, unknown>;
90102
if (typeof v.text !== 'string' || v.text === '')
91103
return { err: `lines[${idx}].text must be a non-empty string` };
104+
if (v.text.length > MAX_LINE_TEXT_LENGTH)
105+
return {
106+
err: `lines[${idx}].text exceeds maximum length of ${MAX_LINE_TEXT_LENGTH} characters`
107+
};
92108
if (v.font !== undefined && typeof v.font !== 'string')
93109
return { err: `lines[${idx}].font must be a string` };
94110
if (v.sizePx !== undefined && (typeof v.sizePx !== 'number' || !Number.isFinite(v.sizePx)))
@@ -110,30 +126,38 @@ function parseSettingsInput(val: unknown): { ok: SettingsInput } | { err: string
110126
if (!val || typeof val !== 'object') return { err: '"settings" must be an object' };
111127
const v = val as Record<string, unknown>;
112128

113-
const PALETTES = new Set(['pastel', 'vivid', 'academic']);
129+
const PALETTE_NAMES = new Set(Object.keys(PALETTES));
114130
const STYLES = new Set(['straight', 'curved']);
115-
const THEMES = new Set(['light', 'dark']);
116-
const BKGS = new Set(['light', 'dark']);
131+
const THEMES_AND_BKGS = new Set(['light', 'dark']);
117132

118-
if (v.palette !== undefined && !PALETTES.has(v.palette as string))
119-
return { err: `settings.palette must be one of: ${[...PALETTES].join(', ')}` };
133+
if (v.palette !== undefined && !PALETTE_NAMES.has(v.palette as string))
134+
return { err: `settings.palette must be one of: ${[...PALETTE_NAMES].join(', ')}` };
120135
if (v.lineStyle !== undefined && !STYLES.has(v.lineStyle as string))
121136
return { err: `settings.lineStyle must be one of: ${[...STYLES].join(', ')}` };
122-
if (v.theme !== undefined && !THEMES.has(v.theme as string))
123-
return { err: `settings.theme must be one of: ${[...THEMES].join(', ')}` };
124-
if (v.background !== undefined && !BKGS.has(v.background as string))
125-
return { err: `settings.background must be one of: ${[...BKGS].join(', ')}` };
126-
if (v.lineThickness !== undefined && (typeof v.lineThickness !== 'number' || !Number.isFinite(v.lineThickness)))
137+
if (v.theme !== undefined && !THEMES_AND_BKGS.has(v.theme as string))
138+
return { err: `settings.theme must be one of: ${[...THEMES_AND_BKGS].join(', ')}` };
139+
if (v.background !== undefined && !THEMES_AND_BKGS.has(v.background as string))
140+
return { err: `settings.background must be one of: ${[...THEMES_AND_BKGS].join(', ')}` };
141+
if (
142+
v.lineThickness !== undefined &&
143+
(typeof v.lineThickness !== 'number' || !Number.isFinite(v.lineThickness))
144+
)
127145
return { err: 'settings.lineThickness must be a number' };
128-
if (v.lineOpacity !== undefined && (typeof v.lineOpacity !== 'number' || !Number.isFinite(v.lineOpacity)))
146+
if (
147+
v.lineOpacity !== undefined &&
148+
(typeof v.lineOpacity !== 'number' || !Number.isFinite(v.lineOpacity))
149+
)
129150
return { err: 'settings.lineOpacity must be a number' };
130151
if (v.showNumbers !== undefined && typeof v.showNumbers !== 'boolean')
131152
return { err: 'settings.showNumbers must be a boolean' };
132153
if (v.colorTokensByLink !== undefined && typeof v.colorTokensByLink !== 'boolean')
133154
return { err: 'settings.colorTokensByLink must be a boolean' };
134155
if (v.tokenSplitChars !== undefined && typeof v.tokenSplitChars !== 'string')
135156
return { err: 'settings.tokenSplitChars must be a string' };
136-
if (v.tokenMergeChar !== undefined && (typeof v.tokenMergeChar !== 'string' || v.tokenMergeChar.length > 1))
157+
if (
158+
v.tokenMergeChar !== undefined &&
159+
(typeof v.tokenMergeChar !== 'string' || v.tokenMergeChar.length > 1)
160+
)
137161
return { err: 'settings.tokenMergeChar must be a single character' };
138162

139163
return {
@@ -164,7 +188,10 @@ function parsePairsInput(val: unknown, lineCount: number): { ok: PairInput[] } |
164188
const upper = pv.upper as number;
165189
const lower = pv.lower as number;
166190
if (upper < 0 || upper >= lineCount) return { err: `pairs[${i}].upper=${upper} out of range` };
167-
if (lower !== upper + 1) return { err: `pairs[${i}]: lower must equal upper + 1 (got upper=${upper}, lower=${lower})` };
191+
if (lower !== upper + 1)
192+
return {
193+
err: `pairs[${i}]: lower must equal upper + 1 (got upper=${upper}, lower=${lower})`
194+
};
168195
if (pv.gapPx !== undefined && (typeof pv.gapPx !== 'number' || !Number.isFinite(pv.gapPx)))
169196
return { err: `pairs[${i}].gapPx must be a number` };
170197
if (pv.showConnectors !== undefined && typeof pv.showConnectors !== 'boolean')
@@ -234,9 +261,7 @@ export function buildAlignUrl(origin: string, req: AlignRequest): AlignResult {
234261
const visualSettings: VisualSettingsV2 = {
235262
...defaults,
236263
...(req.settings
237-
? Object.fromEntries(
238-
Object.entries(req.settings).filter(([, v]) => v !== undefined)
239-
)
264+
? Object.fromEntries(Object.entries(req.settings).filter(([, v]) => v !== undefined))
240265
: {})
241266
};
242267
// Clamp numeric settings to valid ranges
@@ -294,7 +319,12 @@ export function buildAlignUrl(origin: string, req: AlignRequest): AlignResult {
294319

295320
const upperTokenId = upperTokens[upperWordIdx]!.id;
296321
const lowerTokenId = lowerTokens[lowerWordIdx]!.id;
297-
const color = pendingAlignmentColor(connections, [upperTokenId], [lowerTokenId], visualSettings.palette);
322+
const color = pendingAlignmentColor(
323+
connections,
324+
[upperTokenId],
325+
[lowerTokenId],
326+
visualSettings.palette
327+
);
298328
connections.push({ id: createConnectionId(), upperTokenId, lowerTokenId, color });
299329
}
300330

bitext/src/lib/components/editor/Editor.svelte

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,7 @@
4949
<p
5050
class="m-0 max-w-full text-right text-sm leading-snug text-gray-600 dark:text-gray-400 [&>span]:mr-1 [&>span]:last:mr-0"
5151
>
52-
<span
53-
class="inline md:hidden [&>span]:mr-1 [&>span]:inline [&>span]:last:mr-0"
54-
>
52+
<span class="inline md:hidden [&>span]:mr-1 [&>span]:inline [&>span]:last:mr-0">
5553
<span class="sr-only">Whitespace splits words.</span>
5654
<span
5755
>Split: <code class="{chipClass} max-w-[min(100vw-4rem,24rem)] break-all"
@@ -65,9 +63,7 @@
6563
></span
6664
>
6765
</span>
68-
<span
69-
class="hidden md:inline [&>span]:mr-1 [&>span]:inline [&>span]:last:mr-0"
70-
>
66+
<span class="hidden md:inline [&>span]:mr-1 [&>span]:inline [&>span]:last:mr-0">
7167
<span>Whitespace splits words.</span>
7268
<span>Extra split: <code class={chipClass}>{tok.extraSplitChars}</code>.</span>
7369
<span>Join: <code class={chipClass}>{tok.joinChars}</code>.</span>

0 commit comments

Comments
 (0)