t
Some checks failed
BEND FIELD CI / release (push) Has been cancelled
BEND FIELD CI / production-bridge (push) Has been cancelled

This commit is contained in:
33333-33333 2026-08-01 22:31:04 +09:00
commit 9d70afb4cc
42 changed files with 1266 additions and 630 deletions

View file

@ -11,9 +11,29 @@ function normalizePort(value) {
return port;
}
function renderPublicBoundary(){
return `\n`+
` # The service source may share this directory with Apache. Expose only\n`+
` # the curated browser bundle and deny every other direct file request.\n`+
` RewriteRule ^$ - [L]\n`+
` RewriteRule ^(?:index\\.html|style\\.css|favicon\\.(?:svg|ico)|build-meta\\.js|runtime-config\\.js|shared-contracts\\.js|store-catalog\\.(?:generated\\.js|json)|puzzle-patterns\\.js|puzzle-core\\.js|app-logic\\.js|archive-codec\\.js|field-persistence(?:-worker)?\\.js|puzzle-worker\\.js|app\\.js|api-bridge\\.php)$ - [L]\n`+
` RewriteRule ^(?:assets|client)(?:/|$) - [L]\n`+
` RewriteRule ^ - [F,L]\n`;
}
function renderSensitiveFileFallback(){
return `<FilesMatch "(?i)^(?:\\.|server\\.js$|realtime-server\\.js$|package(?:-lock)?\\.json$|build-config\\.json$|README\\.md$)">\n`+
` Require all denied\n`+
`</FilesMatch>\n`;
}
function renderApacheBridge(portValue) {
const port = normalizePort(portValue);
return `${BEGIN_MARKER}\n` +
`ServerSignature Off\n` +
`<IfModule mod_headers.c>\n` +
` Header always unset X-Powered-By\n` +
`</IfModule>\n` +
`<IfModule mod_rewrite.c>\n` +
` RewriteEngine On\n` +
`\n` +
@ -23,17 +43,36 @@ function renderApacheBridge(portValue) {
` RewriteCond %{HTTP:Upgrade} =websocket [NC]\n` +
` RewriteRule ^api/realtime/?$ ws://127.0.0.1:${port}/api/realtime [P,L]\n` +
` </IfModule>\n` +
` RewriteRule ^api/(.*)$ http://127.0.0.1:${port}/api/$1 [P,L]\n` +
` <IfModule mod_proxy_http.c>\n` +
` RewriteRule ^api/(.*)$ http://127.0.0.1:${port}/api/$1 [P,L]\n` +
` </IfModule>\n` +
` </IfModule>\n` +
`\n` +
` # Shared hosts often disable mod_proxy. Route ordinary API requests\n` +
` # through the bundled PHP bridge instead.\n` +
` RewriteCond %{REQUEST_FILENAME} !-f\n` +
` RewriteRule ^api/(.*)$ api-bridge.php?path=/api/$1 [QSA,L]\n` +
renderPublicBoundary()+
`</IfModule>\n` +
`<Files ".linkfield-port">\n` +
` Require all denied\n` +
`</Files>\n` +
renderSensitiveFileFallback()+
`${END_MARKER}\n`;
}
function renderApacheBootstrap(){
return `${BEGIN_MARKER}\n`+
`ServerSignature Off\n`+
`<IfModule mod_headers.c>\n`+
` Header always unset X-Powered-By\n`+
`</IfModule>\n`+
`<IfModule mod_rewrite.c>\n`+
` RewriteEngine On\n\n`+
` # Safe bootstrap: use the bounded PHP bridge until the Node server\n`+
` # writes a verified current proxy port after it begins listening.\n`+
` RewriteCond %{REQUEST_FILENAME} !-f\n`+
` RewriteRule ^api/(.*)$ api-bridge.php?path=/api/$1 [QSA,L]\n`+
renderPublicBoundary()+
`</IfModule>\n`+
renderSensitiveFileFallback()+
`${END_MARKER}\n`;
}
@ -59,4 +98,4 @@ async function installApacheBridge({fsp, root, port, enabled = true} = {}) {
return {enabled:true, written:true, file};
}
module.exports = Object.freeze({BEGIN_MARKER, END_MARKER, renderApacheBridge, replaceManagedBlock, installApacheBridge});
module.exports = Object.freeze({BEGIN_MARKER, END_MARKER, renderApacheBridge,renderApacheBootstrap,replaceManagedBlock,installApacheBridge});

View file

@ -2,6 +2,7 @@
function createJsonRepository({fsp,crypto,processId=process.pid}={}){
if(!fsp?.readFile||!fsp?.writeFile||!fsp?.rename||!crypto?.randomBytes)throw new TypeError('Filesystem and crypto adapters are required');
const retryableRenameCodes=new Set(['EACCES','EBUSY','EPERM']),renameRetryDelays=[20,50,100,200,400,800],sleep=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
const read=async(file,{missing=null}={})=>{
try{return JSON.parse(await fsp.readFile(file,'utf8'))}
catch(error){if(error.code==='ENOENT'&&missing!==undefined)return typeof missing==='function'?missing():missing;throw error}
@ -9,7 +10,7 @@ function createJsonRepository({fsp,crypto,processId=process.pid}={}){
const write=async(file,value)=>{
const temporary=`${file}.${processId}.${crypto.randomBytes(6).toString('hex')}.tmp`;
await fsp.writeFile(temporary,JSON.stringify(value),{encoding:'utf8',mode:0o600});
await fsp.rename(temporary,file);
for(let attempt=0;;attempt++)try{await fsp.rename(temporary,file);break}catch(error){if(!retryableRenameCodes.has(error?.code)||attempt>=renameRetryDelays.length){await fsp.unlink?.(temporary).catch(()=>{});throw error}await sleep(renameRetryDelays[attempt])}
};
const remove=async file=>fsp.unlink(file).catch(error=>{if(error.code!=='ENOENT')throw error});
return Object.freeze({read,write,remove});

37
server/public-health.js Normal file
View file

@ -0,0 +1,37 @@
'use strict';
function deploymentBase(value){
const raw=String(value||'').trim();
if(!raw)return null;
const base=new URL(raw.endsWith('/')?raw:`${raw}/`);
if(!['http:','https:'].includes(base.protocol))throw new Error('LinkField public URL must use HTTP or HTTPS');
return base;
}
async function fetchChecked(url,{accept='application/json',timeoutMs=10_000}={}){
const response=await fetch(url,{headers:{accept},signal:AbortSignal.timeout(timeoutMs)});
return response;
}
async function checkPublicDeployment(publicUrl,{appVersion,timeoutMs=10_000}={}){
const base=deploymentBase(publicUrl);if(!base)return null;
const pageUrl=new URL('',base),apiUrl=new URL('api/cloud/status',base),bridgeUrl=new URL('api-bridge.php?path=/api/cloud/status',base);
const page=await fetchChecked(pageUrl,{accept:'text/html',timeoutMs}),pageBody=await page.text();
if(!page.ok||!pageBody.includes('LinkField'))throw new Error(`Public page failed at ${pageUrl.href}: HTTP ${page.status}`);
const disclosed=page.headers.get('server')||'';if(/\//.test(disclosed))throw new Error(`Public server discloses a detailed version: ${disclosed}`);
for(const relative of ['server.js','package.json','scripts/service-control.js','.linkfield-deployment.json']){
const url=new URL(relative,base),response=await fetchChecked(url,{accept:'text/plain',timeoutMs});
if(![403,404].includes(response.status))throw new Error(`Private deployment file is public at ${url.href}: HTTP ${response.status}`);
await response.body?.cancel().catch(()=>{});
}
const results=[];
for(const [kind,url] of [['API',apiUrl],['PHP bridge',bridgeUrl]]){
const response=await fetchChecked(url,{timeoutMs}),body=await response.json().catch(()=>({}));
if(!response.ok||body?.available!==true||body?.appVersion!==appVersion)throw new Error(`Public ${kind} failed at ${url.href}: HTTP ${response.status}`);
if(kind==='PHP bridge'&&response.headers.get('x-linkfield-bridge')!=='php')throw new Error(`Public PHP bridge did not identify itself at ${url.href}`);
results.push({kind,url:url.href,status:response.status});
}
return{base:base.href,page:pageUrl.href,api:results[0].url,bridge:results[1].url};
}
module.exports=Object.freeze({deploymentBase,checkPublicDeployment});

52
server/world-backup.js Normal file
View file

@ -0,0 +1,52 @@
'use strict';
const fs=require('fs');
const fsp=fs.promises;
const path=require('path');
const crypto=require('crypto');
const PLAYER_FILE=/^[a-f0-9]{16,64}\.json$/i;
const BOARD_FILE=/^B(?:0|[1-9][0-9]*)\.[0-9]+\.json$/;
async function hashFile(file){const hash=crypto.createHash('sha256'),stream=fs.createReadStream(file);for await(const chunk of stream)hash.update(chunk);return hash.digest('hex')}
async function copyVerified(source,destination,{link=false}={}){await fsp.mkdir(path.dirname(destination),{recursive:true,mode:0o700});if(link)try{await fsp.link(source,destination);return await hashFile(destination)}catch(error){if(!['EXDEV','EPERM','EACCES','EEXIST'].includes(error?.code))throw error}await fsp.copyFile(source,destination);await fsp.chmod(destination,0o600).catch(()=>{});return hashFile(destination)}
function backupName(date=new Date()){return date.toISOString().replace(/[:.]/g,'-')}
function safeBackupName(value){const name=String(value||'');if(!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/.test(name))throw new Error('Invalid LinkField backup name');return name}
async function createWorldBackup(dataDirValue,backupRootValue,{retain=7,appVersion='unknown'}={}){
const dataDir=path.resolve(dataDirValue),backupRoot=path.resolve(backupRootValue),name=backupName(),stage=path.join(backupRoot,`.stage-${name}-${process.pid}`),destination=path.join(backupRoot,name);
await fsp.mkdir(backupRoot,{recursive:true,mode:0o700});await fsp.rm(stage,{recursive:true,force:true});await fsp.mkdir(path.join(stage,'data','shared-world.boards'),{recursive:true,mode:0o700});
try{
const worldFile=path.join(dataDir,'shared-world.json'),world=JSON.parse(await fsp.readFile(worldFile,'utf8')),files=['shared-world.json'];
if(!world||!Number.isSafeInteger(world.revision)||!world.boardVersions||typeof world.boardVersions!=='object')throw new Error('Shared world is not valid enough to back up');
for(const[id,revision]of Object.entries(world.boardVersions)){const name=`${id}.${revision}.json`;if(!BOARD_FILE.test(name))throw new Error(`Invalid board reference in shared world: ${name}`);files.push(path.join('shared-world.boards',name))}
const rootNames=await fsp.readdir(dataDir);for(const file of rootNames)if(PLAYER_FILE.test(file))files.push(file);
const hashes={};for(const relative of files){const source=path.join(dataDir,relative),target=path.join(stage,'data',relative);hashes[relative.replace(/\\/g,'/')]=await copyVerified(source,target,{link:relative.startsWith(`shared-world.boards${path.sep}`)})}
const manifest={app:'LinkField',appVersion,createdAt:new Date().toISOString(),worldRevision:world.revision,fileCount:files.length,hashes};
await fsp.writeFile(path.join(stage,'manifest.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o600});
await fsp.rename(stage,destination);
const names=(await fsp.readdir(backupRoot,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&/^\d{4}-/.test(entry.name)).map(entry=>entry.name).sort().reverse();
for(const retired of names.slice(Math.max(1,retain)))await fsp.rm(path.join(backupRoot,retired),{recursive:true,force:true});
return{destination,name,manifest};
}catch(error){await fsp.rm(stage,{recursive:true,force:true}).catch(()=>{});throw error}
}
async function verifyWorldBackup(backupRootValue,nameValue){
const backupRoot=path.resolve(backupRootValue),name=safeBackupName(nameValue),directory=path.join(backupRoot,name),manifest=JSON.parse(await fsp.readFile(path.join(directory,'manifest.json'),'utf8'));
if(manifest?.app!=='LinkField'||!manifest.hashes||typeof manifest.hashes!=='object')throw new Error('Invalid LinkField backup manifest');
for(const[relative,expected]of Object.entries(manifest.hashes)){if(relative.includes('..')||path.isAbsolute(relative))throw new Error('Unsafe backup path');const actual=await hashFile(path.join(directory,'data',relative));if(actual!==expected)throw new Error(`Backup checksum mismatch: ${relative}`)}
return{directory,manifest,name};
}
async function restoreWorldBackup(dataDirValue,backupRootValue,nameValue){
const dataDir=path.resolve(dataDirValue),verified=await verifyWorldBackup(backupRootValue,nameValue),parent=path.dirname(dataDir),stamp=Date.now(),stage=path.join(parent,`.${path.basename(dataDir)}.restore-${stamp}`),previous=path.join(parent,`${path.basename(dataDir)}.pre-restore-${stamp}`);
await fsp.rm(stage,{recursive:true,force:true});await copyDirectory(path.join(verified.directory,'data'),stage);
if(fs.existsSync(dataDir))await fsp.rename(dataDir,previous);
try{await fsp.rename(stage,dataDir)}catch(error){if(fs.existsSync(previous)&&!fs.existsSync(dataDir))await fsp.rename(previous,dataDir);throw error}
return{restored:verified.name,previous};
}
async function copyDirectory(source,destination){const stat=await fsp.lstat(source);if(stat.isSymbolicLink())throw new Error('Backup contains a symbolic link');if(stat.isDirectory()){await fsp.mkdir(destination,{recursive:true,mode:0o700});for(const entry of await fsp.readdir(source))await copyDirectory(path.join(source,entry),path.join(destination,entry));return}if(stat.isFile())await copyVerified(source,destination)}
module.exports=Object.freeze({backupName,createWorldBackup,verifyWorldBackup,restoreWorldBackup});