diff --git a/scripts/publish/README.md b/scripts/publish/README.md
index 024a7548afa25..ec777c4e6a34a 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 0000000000000..780a2c8770ad5
--- /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','Home');
+ write(prefix+'archived-docs/index.html','Archived Versions');
+ 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 0000000000000..7b7170f3f388e
--- /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 f07e3ccb5c941..aaab1c8d1c2ba 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('','',
'Header always set Cache-Control "no-store" env=DORIS_ASSET_REDIRECT','',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,'','RewriteEngine On',
- 'RewriteOptions IgnoreInherit',...lines,'',end,''].join('\n'));
+ const content = [begin,'','RewriteEngine On',
+ 'RewriteOptions IgnoreInherit',...lines,'',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 38bf8e7f9d278..d81acd15507d3 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)) {