Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- Fix: 一部のUI要素の色が正しく表示されない問題を修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/1243)
- Fix: 「D」キーでダークモードを切り替える際にsyncDeviceDarkModeのチェックがバイパスされる問題を修正
- Fix: 起動時に unhandled rejection が発生するとブートエラー画面が無応答になる問題を修正
Comment thread
fruitriin marked this conversation as resolved.
Outdated

### Server
- Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善
Expand Down
64 changes: 48 additions & 16 deletions packages/frontend-embed/public/loader/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@

// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => {
// renderError 自身が throw すると、それが unhandledrejection を再発火させて
// onunhandledrejection ハンドラから renderError が再呼び出しされる無限ループに陥り、
// メインスレッドを焼き尽くしてエラー画面が操作不能になる。
// それを防ぐための再入ガード。
let renderErrorRunning = false;

window.onerror = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED');
Expand Down Expand Up @@ -80,38 +86,64 @@
document.head.appendChild(css);
}

function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

async function renderError(code) {
if (renderErrorRunning) return;
renderErrorRunning = true;
try {
await renderErrorImpl(code);
} catch (e) {
try { console.error('renderError failed', e); } catch { /* noop */ }
} finally {
renderErrorRunning = false;
}
}

async function renderErrorImpl(code) {
// Cannot set property 'innerHTML' of null を回避
if (document.readyState === 'loading') {
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}

let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
try {
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
} catch { /* localStorage / JSON.parse の失敗は無視 */ }
if (!messages) {
try {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
} catch { /* localStorage / JSON.parse の失敗は無視 */ }
}
if (!messages) messages = {};

const title = messages?.title || 'Failed to initialize Misskey';
const reload = messages?.reload || 'Reload';

// 補間値は HTML エスケープ済み (XSS 対策)
document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
<div class="message">${title}</div>
<div class="submessage">Error Code: ${code}</div>
<div class="message">${escapeHtml(title)}</div>
<div class="submessage">Error Code: ${escapeHtml(code)}</div>
<button onclick="location.reload(!0)">
<div>${reload}</div>
<div>${escapeHtml(reload)}</div>
</button>`;
addStyle(`
#misskey_app,
Expand Down
136 changes: 107 additions & 29 deletions packages/frontend/public/loader/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@

// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => {
// renderError 自身が throw すると、それが unhandledrejection を再発火させて
// onunhandledrejection ハンドラから renderError が再呼び出しされる無限ループに陥り、
// メインスレッドを焼き尽くしてエラー画面が操作不能になる。
// それを防ぐための再入ガード。
let renderErrorRunning = false;

window.onerror = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED', e);
};
window.onunhandledrejection = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED_IN_PROMISE', e.reason || e);
// e.reason は falsy 値 (0, '', null 等) で reject される可能性があるため
// `||` ではなく in 演算子で存在確認する
renderError('SOMETHING_HAPPENED_IN_PROMISE', 'reason' in e ? e.reason : e);
};

let forceError = localStorage.getItem('forceError');
Expand Down Expand Up @@ -127,27 +135,92 @@
document.head.appendChild(css);
}

function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function safeToString(v) {
try {
if (v == null) return String(v);
if (typeof v.toString === 'function') {
const s = v.toString();
if (typeof s === 'string') return s;
}
// Object.create(null) 等 toString を持たないオブジェクト向けの最終フォールバック
return Object.prototype.toString.call(v);
} catch {
return '[unserializable]';
}
}

function safeJsonStringify(v) {
// 兄弟関係で同じ参照が現れる (`{a: x, b: x}`) ケースを循環と誤検出しないよう、
// 祖先パスのみを追跡する。replacer 内の this はその値を保持する親オブジェクトを指す
// 仕様 (https://tc39.es/ecma262/#sec-json.stringify) を利用して、今回の親より下の
// 祖先をスタックから巻き戻してから判定する。
const ancestors = [];
try {
return JSON.stringify(v, function (_key, value) {
if (typeof value === 'bigint') return value.toString() + 'n';
if (typeof value !== 'object' || value === null) return value;
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
ancestors.pop();
}
if (ancestors.includes(value)) return '[Circular]';
ancestors.push(value);
return value;
}) ?? '';
} catch {
return '';
}
}

async function renderError(code, details) {
if (renderErrorRunning) return;
renderErrorRunning = true;
try {
await renderErrorImpl(code, details);
} catch (e) {
try { console.error('renderError failed', e); } catch { /* noop */ }
} finally {
renderErrorRunning = false;
}
}

async function renderErrorImpl(code, details) {
// Cannot set property 'innerHTML' of null を回避
if (document.readyState === 'loading') {
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}

// 既にエラー画面が描画済みなら、同じ id を二重に挿入しないよう抜ける
// (連発するエラーは console.error 済み)
if (document.getElementById('errorInfo')) return;

let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
try {
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
} catch { /* localStorage / JSON.parse の失敗は無視 */ }
if (!messages) {
try {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
} catch { /* localStorage / JSON.parse の失敗は無視 */ }
}
if (!messages) messages = {};

Expand All @@ -172,44 +245,47 @@
let errorsElement = document.getElementById('errors');

if (!errorsElement) {
// 補間値は HTML エスケープ済み (XSS 対策)。
// messages は localStorage 由来で origin scoped だが、frontend-embed 側と
// 防御方針を揃えるためここでも一律エスケープする。
document.body.innerHTML = `
<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 9v2m0 4v.01"></path>
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
</svg>
<h1>${messages.title}</h1>
<h1>${escapeHtml(messages.title)}</h1>
<button class="button-big" onclick="location.reload(true);">
<span class="button-label-big">${messages?.reload}</span>
<span class="button-label-big">${escapeHtml(messages.reload)}</span>
</button>
<p><b>${messages.solution}</b></p>
<p>${messages.solution1}</p>
<p>${messages.solution2}</p>
<p>${messages.solution3}</p>
<p>${messages.solution4}</p>
<p><b>${escapeHtml(messages.solution)}</b></p>
<p>${escapeHtml(messages.solution1)}</p>
<p>${escapeHtml(messages.solution2)}</p>
<p>${escapeHtml(messages.solution3)}</p>
<p>${escapeHtml(messages.solution4)}</p>
<details style="color: #86b300;">
<summary>${messages.otherOption}</summary>
<a href="${safeModeUrl}">
<summary>${escapeHtml(messages.otherOption)}</summary>
<a href="${escapeHtml(safeModeUrl.toString())}">
<button class="button-small">
<span class="button-label-small">${messages.otherOption4}</span>
<span class="button-label-small">${escapeHtml(messages.otherOption4)}</span>
</button>
</a>
<br>
<a href="/flush">
<button class="button-small">
<span class="button-label-small">${messages.otherOption1}</span>
<span class="button-label-small">${escapeHtml(messages.otherOption1)}</span>
</button>
</a>
<br>
<a href="/cli">
<button class="button-small">
<span class="button-label-small">${messages.otherOption2}</span>
<span class="button-label-small">${escapeHtml(messages.otherOption2)}</span>
</button>
</a>
<br>
<a href="/bios">
<button class="button-small">
<span class="button-label-small">${messages.otherOption3}</span>
<span class="button-label-small">${escapeHtml(messages.otherOption3)}</span>
</button>
</a>
</details>
Expand All @@ -220,12 +296,14 @@
}
const detailsElement = document.createElement('details');
detailsElement.id = 'errorInfo';
// details は循環参照や toString 不在、BigInt 等で throw する可能性があるため安全化する
// (XSS 対策として HTML エスケープも行う)
detailsElement.innerHTML = `
<br>
<summary>
<code>ERROR CODE: ${code}</code>
<code>ERROR CODE: ${escapeHtml(code)}</code>
</summary>
<code>${details.toString()} ${JSON.stringify(details)}</code>`;
<code>${escapeHtml(safeToString(details))} ${escapeHtml(safeJsonStringify(details))}</code>`;
errorsElement.appendChild(detailsElement);
addStyle(`
* {
Expand Down
Loading