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)) {