Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions scripts/publish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions scripts/publish/__tests__/apache-controls.test.cjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
21 changes: 21 additions & 0 deletions scripts/publish/apache-controls.cjs
Original file line number Diff line number Diff line change
@@ -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};
39 changes: 29 additions & 10 deletions scripts/publish/optimize.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion scripts/publish/verify.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)) {
Expand Down
Loading