This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-website.git
The following commit(s) were added to refs/heads/master by this push:
new 012e6a918be2 [fix](website) Prevent Apache 500 errors from oversized
redirect rules (#4566)
012e6a918be2 is described below
commit 012e6a918be232be241485742438e267fd4a546f
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sat Oct 31 08:01:02 2026 +0000
[fix](website) Prevent Apache 500 errors from oversized redirect rules
(#4566)
Archiving the historical documentation made thousands of aliases point
to the same archive page. The publication optimizer combined each
destination's aliases into one `RewriteRule`, producing a 263,259-byte
line in the artifact from [run
41149820126](https://github.com/apache/doris-website/actions/runs/41149820126).
Apache rejects `.htaccess` lines above [8,190
characters](https://httpd.apache.org/docs/2.4/configuring.html#syntax),
so uncached website requests returned HTTP 500 while cached pages still
showed the old 2.1 navigation entry.
This change splits grouped redirects into complete directives of at most
8,000 UTF-8 bytes, including escaped paths, targets and flags. It
applies to both root and static-resource controls. Individual redirects
that cannot fit fail the build. The existing publication verifier also
rejects oversized control lines, including continued lines, before the
artifact can be uploaded.
## Validation
- `DORIS_TEST_APACHE=true node --test
scripts/publish/__tests__/*.test.cjs`: 30 passed, zero skips. New
regressions cover 9,000 archive/asset aliases across five locales, UTF-8
byte limits, oversized individual sources/targets, nested controls,
continued lines, query/fragment handling and real Apache responses.
- `node --test scripts/__tests__/build-locales.test.cjs`: 11 passed,
including preservation of the previous artifact after publication
verification fails.
- `NODE_OPTIONS=--max-old-space-size=2048 node
scripts/publish/verify-fixture.cjs --apache`: five-locale Rspack
production fixture and Apache HTTP checks passed.
- Replayed all 92,059 redirect entries from deployment commit `8321964`
locally, using its actual homepage/archive HTML and placeholder redirect
destinations. The original controls reproduced HTTP 500 with `Line too
long`; regenerated controls have a maximum line length of 8,000 bytes.
All ten locale homepage/archive requests returned 200, and 479 sampled
redirect requests matched their expected status and destination.
- `git diff --check`: passed. The full website build is left to the
existing Build Check workflow.
## Self-review
- Goal: the deployed failure is reproduced and resolved with the same
redirect inventory.
- Scope: changes are confined to publication controls, artifact
verification, regression tests and their README.
- Information architecture: active documentation versions, localized
destinations and archive routing retain their existing behavior.
- URL compatibility: exact aliases, trailing-slash/index variants,
query/fragment behavior and temporary asset redirects are covered by
Apache checks.
- Frontend and workflow conventions: existing build/verification entry
points are reused; React, styling and workflow permissions are outside
this change.
- Verification: the tests cover the failure at both fixture and
production-inventory scale; CI will run the complete publishing build.
- Other correctness, accessibility, SEO and maintainability checks: no
additional issues found in this focused review.
## Versions
- [x] dev
- [x] 4.x
- [x] 3.x
- [x] 2.1 or older (not covered by version/language sync gate)
## Languages
- [x] Chinese
- [x] English
The common publication path also covers Japanese, Korean and Russian. No
per-language documentation edits are required.
## Docs Checklist
- [x] Checked by AI
- [x] Test Cases Built
- [x] Updated required version and language counterparts, or explained
why not
- [x] If only one language changed, confirmed whether source/translation
counterparts need sync
Co-authored-by: morningman <[email protected]>
---
scripts/publish/README.md | 11 +-
scripts/publish/__tests__/apache-controls.test.cjs | 114 +++++++++++++++++++++
scripts/publish/apache-controls.cjs | 21 ++++
scripts/publish/optimize.cjs | 39 +++++--
scripts/publish/verify.cjs | 5 +-
5 files changed, 175 insertions(+), 15 deletions(-)
diff --git a/scripts/publish/README.md b/scripts/publish/README.md
index 024a7548afa2..ec777c4e6a34 100644
--- a/scripts/publish/README.md
+++ b/scripts/publish/README.md
@@ -20,8 +20,10 @@ retain their existing asset layout.
present because the asset CDN does not interpret Apache rules.
- Exact Docusaurus client-redirect templates become HTTP 301 rules. Large
`.html`/`.htm`, 3.x and release-note alias families use guarded prefix rules;
- other aliases are grouped by destination. Real files retain priority, missing
- destinations do not redirect through a generic rule, and query/fragment
+ other aliases are grouped by destination and split into directives of at most
+ 8,000 UTF-8 bytes, below Apache's 8,190-byte `.htaccess` line limit. The
bound
+ includes escaped paths, redirect targets and flags. Real files retain
priority,
+ missing destinations do not redirect through a generic rule, and
query/fragment
forwarding follows the original template. Custom HTML such as the tracked
Slack redirect remains a page.
Binary compatibility rules live in their static-resource directories, so
@@ -80,8 +82,9 @@ yarn serve:publish --site .i18n-build/dev-publish
redirect pages, rule counts and verification. `.i18n-build/build-result.json`
also records per-locale timing and build success. Counts describe raw files,
not compressed network transfer or Git pack size. Source changes, invalid
-redirects, resource verification failures and asset collisions preserve the
-previous complete output.
+redirects, oversized Apache control lines (including continued lines and
+static-resource controls), resource verification failures and asset collisions
+preserve the previous complete output.
Changes to publication infrastructure make Build Check build every active
version and enabled locale using the optimized mode on CI, with the existing
diff --git a/scripts/publish/__tests__/apache-controls.test.cjs
b/scripts/publish/__tests__/apache-controls.test.cjs
new file mode 100644
index 000000000000..780a2c8770ad
--- /dev/null
+++ b/scripts/publish/__tests__/apache-controls.test.cjs
@@ -0,0 +1,114 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const {writeApache, inventory} = require('../optimize.cjs');
+const {verify} = require('../verify.cjs');
+
+const locales = ['en','zh-CN','ja','ko','ru'].map(locale =>
({locale,directory:locale === 'en' ? '.' : locale}));
+
+function fixture(t, bulk = false) {
+ const site =
fs.mkdtempSync(path.join(os.tmpdir(),'doris-apache-controls-'));
+ t.after(() => fs.rmSync(site,{recursive:true,force:true}));
+ const write = (file,content) => {
+ fs.mkdirSync(path.dirname(path.join(site,file)),{recursive:true});
+ fs.writeFileSync(path.join(site,file),content);
+ };
+ const redirects = new Map();
+ write('.htaccess','# Original controls\n');
+ write('assets/shared/image.png','image');
+ for (const locale of locales) {
+ const prefix = locale.directory === '.' ? '' : locale.directory+'/';
+ write(prefix+'index.html','<main>Home</main>');
+ write(prefix+'archived-docs/index.html','<main>Archived
Versions</main>');
+ if (!bulk) continue;
+ for (let i = 0; i < 600; i++) {
+ redirects.set('/'+prefix+`docs/2.1/sql-manual/历史函数-${i}/`,
+
{to:'/'+prefix+'archived-docs/',preserveQuery:true,status:301});
+ redirects.set('/'+prefix+`docs/2.0/sql-manual/历史函数-${i}/`,
+
{to:'/'+prefix+'archived-docs/?version=2.0#downloads',preserveQuery:false,status:301});
+ redirects.set('/'+prefix+`images/历史图片-${i}.png`,
+ {to:'/assets/shared/image.png',preserveQuery:true,status:302});
+ }
+ }
+
write('.publish/redirects.json',JSON.stringify({assetPrefixes:[],redirects:[...redirects]
+ .map(([from,item]) =>
[from,item.to,item.preserveQuery,item.status])}));
+ return {site,write,redirects};
+}
+
+test('bounds many-to-one archive and Unicode asset rules in every locale by
UTF-8 bytes', t => {
+ const {site,redirects} = fixture(t,true);
+ writeApache({site,locales,redirects});
+ const controls = inventory(site).filter(item => path.basename(item.file)
=== '.htaccess');
+ assert.equal(controls.length,6);
+ for (const item of controls) {
+ for (const line of fs.readFileSync(item.file,'utf8').split('\n')) {
+ assert(Buffer.byteLength(line) <= 8000,`${item.relative}:
${Buffer.byteLength(line)} bytes`);
+ }
+ }
+ verify(site);
+});
+
+test('rejects an individual redirect that cannot fit into an Apache rule', t
=> {
+ for (const field of ['source','target']) {
+ const {site} = fixture(t);
+ const source = field === 'source' ? '/'+ '长'.repeat(3000) : '/legacy/';
+ const to = field === 'target' ? '/'+ 'x'.repeat(8000) :
'/archived-docs/';
+ assert.throws(() => writeApache({site,locales,redirects:new Map([
+ [source,{to,preserveQuery:true,status:301}],
+ ])}),/Apache.*line/i,field);
+ }
+});
+
+test('publication verification rejects oversized root and nested Apache
controls', t => {
+ const {site,write,redirects} = fixture(t);
+ writeApache({site,locales,redirects});
+ for (const file of ['.htaccess','zh-CN/images/.htaccess']) {
+ // Fewer than 8,000 characters can still exceed the limit in UTF-8.
+ write(file,'#'+'长'.repeat(3000)+'\n');
+ assert.throws(() => verify(site),error => error.message.includes(file)
&& /Apache.*line/i.test(error.message));
+ write(file,'# Valid controls\n');
+ }
+ // Apache joins continuation lines before checking the line limit.
+ write('.htaccess','#'+'x'.repeat(5000)+'\\\n'+'x'.repeat(5000)+'\n');
+ assert.throws(() => verify(site),/Apache.*line/i);
+});
+
+test('Apache serves pages and large archive/asset redirect groups after
splitting',
+ {skip:process.env.DORIS_TEST_APACHE !== 'true'}, async t => {
+ const {site,redirects} = fixture(t,true);
+ writeApache({site,locales,redirects});
+ const server = await require('../apache.cjs').apache(site);
+ t.after(() => server.close());
+ const probe = async (source,options) => {
+ const response = await fetch(server.origin+source,options);
+ await response.arrayBuffer();
+ return response;
+ };
+ for (const locale of locales) {
+ const prefix = locale.directory === '.' ? '' : locale.directory+'/';
+ assert.equal((await probe('/'+prefix)).status,200);
+ assert.equal((await probe('/'+prefix+'archived-docs/')).status,200);
+ for (const i of [0,99,100,299,599]) {
+ const from = '/'+prefix+`docs/2.1/sql-manual/历史函数-${i}/`;
+ for (const source of [from,from.slice(0,-1),from+'index.html']) {
+ const response = await
probe(source+'?probe=1',{redirect:'manual'});
+ assert.equal(response.status,301,source);
+ assert.equal(new
URL(response.headers.get('location')).pathname,'/'+prefix+'archived-docs/');
+ assert.equal(new
URL(response.headers.get('location')).search,'?probe=1');
+ }
+ const fixed = await
probe('/'+prefix+`docs/2.0/sql-manual/历史函数-${i}/?probe=1`,{redirect:'manual'});
+ assert.equal(fixed.status,301);
+ assert.equal(new
URL(fixed.headers.get('location')).pathname,'/'+prefix+'archived-docs/');
+ assert.equal(new
URL(fixed.headers.get('location')).search,'?version=2.0');
+ assert.equal(new
URL(fixed.headers.get('location')).hash,'#downloads');
+ const image = await
probe('/'+prefix+`images/历史图片-${i}.png?probe=1`,{redirect:'manual'});
+ assert.equal(image.status,302);
+ assert.equal(new
URL(image.headers.get('location')).pathname,'/assets/shared/image.png');
+ assert.equal(new
URL(image.headers.get('location')).search,'?probe=1');
+ assert.equal(image.headers.get('cache-control'),'no-store');
+ }
+ assert.equal((await
probe('/'+prefix+'docs/2.1/unknown/',{redirect:'manual'})).status,404);
+ }
+});
diff --git a/scripts/publish/apache-controls.cjs
b/scripts/publish/apache-controls.cjs
new file mode 100644
index 000000000000..7b7170f3f388
--- /dev/null
+++ b/scripts/publish/apache-controls.cjs
@@ -0,0 +1,21 @@
+const assert = require('node:assert/strict');
+
+// Apache limits .htaccess lines to 8,190 bytes. Leave room for line endings
+// and count the complete escaped directive, including its target and flags.
+const maxLineBytes = 8000;
+
+function validateApacheControls(content, source) {
+ let bytes = 0, start = 1;
+ for (const [index,line] of content.split(/\r?\n/).entries()) {
+ bytes += Buffer.byteLength(line);
+ assert(bytes <= maxLineBytes,
+ `Apache control line exceeds ${maxLineBytes} bytes:
${source}:${start} (${bytes} bytes)`);
+ // Continued physical lines form one logical configuration line.
+ if (!line.endsWith('\\')) {
+ bytes = 0;
+ start = index+2;
+ }
+ }
+}
+
+module.exports = {maxLineBytes,validateApacheControls};
diff --git a/scripts/publish/optimize.cjs b/scripts/publish/optimize.cjs
index f07e3ccb5c94..aaab1c8d1c2b 100644
--- a/scripts/publish/optimize.cjs
+++ b/scripts/publish/optimize.cjs
@@ -2,6 +2,7 @@ const fs = require('node:fs');
const path = require('node:path');
const {createHash} = require('node:crypto');
const assert = require('node:assert/strict');
+const {maxLineBytes,validateApacheControls} = require('./apache-controls.cjs');
const binary =
/\.(?:png|jpe?g|gif|webp|avif|svg|ico|ttf|otf|woff2?|eot|pdf|zip|docx?|xlsx?|mp[34]|webm|wav|ogg)$/i;
const begin = '# BEGIN DORIS PUBLISH OPTIMIZATION';
@@ -238,13 +239,27 @@ function
writeApache({site,locales,redirects,replaceExisting = false}) {
// Quote and escape config syntax, while leaving the already encoded
// redirect target intact (NE avoids double encoding it).
const target =
item.to.replace(/\\/g,'\\\\').replace(/"/g,'\\"').replace(/\$/g,'\\$').replace(/%/g,'\\%');
- const pattern = from.map(value => escapeRegex(value.path)
- + (value.directory ? '(?:/index\\.html|/)?' :
'')).join('|').replace(/"/g,'\\"');
- const rule = `RewriteRule "^(?:${pattern})$" "${target}"
[R=${item.status},L,NE${item.preserveQuery ? '' : ',QSD'}${item.status === 302
? ',E=DORIS_ASSET_REDIRECT:1' : ''}]`;
- if (directory) {
- if (!assetRules.has(directory)) assetRules.set(directory,[]);
- assetRules.get(directory).push(rule);
- } else rules.push(rule);
+ const prefix = 'RewriteRule "^(?:';
+ const suffix = `)$" "${target}"
[R=${item.status},L,NE${item.preserveQuery ? '' : ',QSD'}${item.status === 302
? ',E=DORIS_ASSET_REDIRECT:1' : ''}]`;
+ const overhead = Buffer.byteLength(prefix+suffix);
+ if (directory && !assetRules.has(directory))
assetRules.set(directory,[]);
+ const output = directory ? assetRules.get(directory) : rules;
+ let patterns = [], bytes = overhead;
+ for (const value of from) {
+ const pattern = (escapeRegex(value.path)
+ + (value.directory ? '(?:/index\\.html|/)?' :
'')).replace(/"/g,'\\"');
+ const size = Buffer.byteLength(pattern);
+ assert(overhead+size <= maxLineBytes,
+ `Publication redirect cannot fit in an Apache control line of
${maxLineBytes} bytes`);
+ if (bytes+size+(patterns.length ? 1 : 0) > maxLineBytes) {
+ output.push(prefix+patterns.join('|')+suffix);
+ patterns = [];
+ bytes = overhead;
+ }
+ bytes += size+(patterns.length ? 1 : 0);
+ patterns.push(pattern);
+ }
+ if (patterns.length) output.push(prefix+patterns.join('|')+suffix);
}
rules.push('</IfModule>','<IfModule mod_headers.c>',
'Header always set Cache-Control "no-store"
env=DORIS_ASSET_REDIRECT','</IfModule>',end);
@@ -255,14 +270,18 @@ function
writeApache({site,locales,redirects,replaceExisting = false}) {
assert(finish >= end.length, 'Incomplete publication controls');
original =
original.slice(0,original.indexOf(begin))+original.slice(finish).replace(/^\n+/,'');
}
- fs.writeFileSync(controls,rules.join('\n')+'\n\n'+original);
+ const content = rules.join('\n')+'\n\n'+original;
+ validateApacheControls(content,controls);
+ fs.writeFileSync(controls,content);
for (const [directory,lines] of assetRules) {
const file = path.join(site,directory,'.htaccess');
assert(!fs.existsSync(file) || (replaceExisting &&
fs.readFileSync(file,'utf8').startsWith(begin)),
`Cannot replace existing asset controls: ${directory}`);
fs.mkdirSync(path.dirname(file),{recursive:true});
- fs.writeFileSync(file,[begin,'<IfModule mod_rewrite.c>','RewriteEngine
On',
- 'RewriteOptions
IgnoreInherit',...lines,'</IfModule>',end,''].join('\n'));
+ const content = [begin,'<IfModule mod_rewrite.c>','RewriteEngine On',
+ 'RewriteOptions
IgnoreInherit',...lines,'</IfModule>',end,''].join('\n');
+ validateApacheControls(content,file);
+ fs.writeFileSync(file,content);
}
return {root:rules.filter(line => line.startsWith('RewriteRule')).length,
total:rules.filter(line => line.startsWith('RewriteRule')).length
diff --git a/scripts/publish/verify.cjs b/scripts/publish/verify.cjs
index 38bf8e7f9d27..d81acd15507d 100644
--- a/scripts/publish/verify.cjs
+++ b/scripts/publish/verify.cjs
@@ -3,6 +3,7 @@ const path = require('node:path');
const assert = require('node:assert/strict');
const {Script} = require('node:vm');
const {inventory} = require('./optimize.cjs');
+const {validateApacheControls} = require('./apache-controls.cjs');
function verify(site) {
site = path.resolve(site);
@@ -36,7 +37,9 @@ function verify(site) {
references++;
};
for (const item of files) {
- if (item.relative.endsWith('.html')) {
+ if (path.basename(item.file) === '.htaccess') {
+
validateApacheControls(fs.readFileSync(item.file,'utf8'),item.relative);
+ } else if (item.relative.endsWith('.html')) {
const content = fs.readFileSync(item.file,'utf8');
for (const tag of
content.matchAll(/<(?:script|link|img|source|video|audio)\b[^>]*>/g)) {
for (const attribute of
tag[0].matchAll(/\b(?:src|href)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g)) {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]