fix(posimai-sc): セキュリティ・品質監査対応(CSP/DOMPurify/reactivity 他)
- [H1] CSP meta タグ追加(script/style/connect/frame-ancestors 等)
- [H2] posimai_api_key を sessionStorage に移行(localStorage フォールバック付き)
- [H3] DOMPurify を USE_PROFILES から明示 ALLOWED_TAGS/ALLOWED_ATTR ホワイトリストに変更
- [M1] progress/scores の直接変更を spread 再代入に統一(Alpine reactivity 保証)
- [M2] .catch(()=>{}) を console.warn 付きに変更(サイレント消滅を防止)
- [M3] syncHeader を this.syncToken 一本化(localStorage 二重読み解消)
- [M5] SW の cache.put() 失敗を捕捉してログ出力
- [M6/M7] sidebar-item に aria-label/aria-pressed 追加、main に tabindex=-1 + focus()
- [LOW] glossary x-for キーをコンテンツベースに変更、SW コメント補足
- SW v7 → v8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
50e95577d7
commit
96208363d9
|
|
@ -2,6 +2,19 @@
|
||||||
<html lang="ja" data-app-id="posimai-sc">
|
<html lang="ja" data-app-id="posimai-sc">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
|
default-src 'self';
|
||||||
|
script-src 'self' https://cdn.jsdelivr.net https://unpkg.com 'unsafe-inline' 'unsafe-eval';
|
||||||
|
style-src 'self' https://fonts.googleapis.com 'unsafe-inline';
|
||||||
|
font-src https://fonts.gstatic.com;
|
||||||
|
connect-src 'self' https://api.soar-enrich.com;
|
||||||
|
img-src 'self' data:;
|
||||||
|
worker-src 'self';
|
||||||
|
manifest-src 'self';
|
||||||
|
frame-ancestors 'none';
|
||||||
|
base-uri 'self';
|
||||||
|
form-action 'self'
|
||||||
|
">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
|
|
@ -421,6 +434,8 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
|
||||||
<template x-for="u in cat.units" :key="u.id">
|
<template x-for="u in cat.units" :key="u.id">
|
||||||
<div class="sidebar-item"
|
<div class="sidebar-item"
|
||||||
role="button" tabindex="0"
|
role="button" tabindex="0"
|
||||||
|
:aria-label="u.num + ': ' + u.title"
|
||||||
|
:aria-pressed="!!(currentUnit && currentUnit.id===u.id)"
|
||||||
:class="{active: currentUnit && currentUnit.id===u.id, done: isDone(u.id)}"
|
:class="{active: currentUnit && currentUnit.id===u.id, done: isDone(u.id)}"
|
||||||
@click="openUnit(u); sidebarOpen=false"
|
@click="openUnit(u); sidebarOpen=false"
|
||||||
@keydown.enter.prevent="openUnit(u); sidebarOpen=false"
|
@keydown.enter.prevent="openUnit(u); sidebarOpen=false"
|
||||||
|
|
@ -436,7 +451,7 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main id="main">
|
<main id="main" tabindex="-1">
|
||||||
<!-- HOME -->
|
<!-- HOME -->
|
||||||
<div x-show="!currentUnit && !weakDrillActive && !glossaryViewActive && !examActive">
|
<div x-show="!currentUnit && !weakDrillActive && !glossaryViewActive && !examActive">
|
||||||
<div class="home-hero">
|
<div class="home-hero">
|
||||||
|
|
@ -549,7 +564,7 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
|
||||||
</div>
|
</div>
|
||||||
<div class="glossary-meta-line" x-text="filteredGlossary.length + ' 件 / 全 ' + glossaryRows.length + ' 件'"></div>
|
<div class="glossary-meta-line" x-text="filteredGlossary.length + ' 件 / 全 ' + glossaryRows.length + ' 件'"></div>
|
||||||
<div class="glossary-list">
|
<div class="glossary-list">
|
||||||
<template x-for="(row, gi) in filteredGlossary" :key="'g'+gi+'-'+row.unitId+'-'+row.term">
|
<template x-for="row in filteredGlossary" :key="row.unitId+'-'+row.term">
|
||||||
<div class="glossary-row" role="button" tabindex="0"
|
<div class="glossary-row" role="button" tabindex="0"
|
||||||
@click="openUnitFromGlossary(row.unitId)"
|
@click="openUnitFromGlossary(row.unitId)"
|
||||||
@keydown.enter.prevent="openUnitFromGlossary(row.unitId)"
|
@keydown.enter.prevent="openUnitFromGlossary(row.unitId)"
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ function sanitizeTrustedHtml(dirty) {
|
||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
return window.DOMPurify.sanitize(s, {
|
return window.DOMPurify.sanitize(s, {
|
||||||
USE_PROFILES: { html: true, svg: true, svgFilters: true },
|
ALLOWED_TAGS: ['p', 'div', 'span', 'strong', 'em', 'br', 'ul', 'ol', 'li', 'code', 'pre'],
|
||||||
|
ALLOWED_ATTR: ['class'],
|
||||||
ALLOW_UNKNOWN_PROTOCOLS: false
|
ALLOW_UNKNOWN_PROTOCOLS: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +155,8 @@ document.addEventListener('alpine:init', () => {
|
||||||
catch{ this.wrongUnits = {}; }
|
catch{ this.wrongUnits = {}; }
|
||||||
this.syncStateAt = parseInt(localStorage.getItem('posimai-sc-state-at') || '0', 10) || 0;
|
this.syncStateAt = parseInt(localStorage.getItem('posimai-sc-state-at') || '0', 10) || 0;
|
||||||
const jwtToken = (localStorage.getItem('posimai_token') || '').trim();
|
const jwtToken = (localStorage.getItem('posimai_token') || '').trim();
|
||||||
const apiKey = (localStorage.getItem(SYNC_KEY_LS) || '').trim();
|
// posimai_api_key: sessionStorage 優先、旧 localStorage からの移行フォールバック
|
||||||
|
const apiKey = (sessionStorage.getItem(SYNC_KEY_LS) || localStorage.getItem(SYNC_KEY_LS) || '').trim();
|
||||||
this.syncToken = jwtToken || apiKey;
|
this.syncToken = jwtToken || apiKey;
|
||||||
this.syncEnabled = !!this.syncToken;
|
this.syncEnabled = !!this.syncToken;
|
||||||
const t = localStorage.getItem('posimai-sc-theme')||'system';
|
const t = localStorage.getItem('posimai-sc-theme')||'system';
|
||||||
|
|
@ -171,12 +173,12 @@ document.addEventListener('alpine:init', () => {
|
||||||
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
|
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
|
||||||
});
|
});
|
||||||
if(this.syncEnabled){
|
if(this.syncEnabled){
|
||||||
this.pullCloudState().catch(()=>{});
|
this.pullCloudState().catch(e=>{ console.warn('[sc] pullCloudState failed:', e); });
|
||||||
} else {
|
} else {
|
||||||
this.syncStatus = '同期キー未設定';
|
this.syncStatus = '同期キー未設定';
|
||||||
}
|
}
|
||||||
if('serviceWorker' in navigator){
|
if('serviceWorker' in navigator){
|
||||||
navigator.serviceWorker.register('/sw.js').catch(()=>{});
|
navigator.serviceWorker.register('/sw.js').catch(e=>{ console.warn('[sc] SW register failed:', e); });
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown',(e)=>{
|
document.addEventListener('keydown',(e)=>{
|
||||||
const t=e.target;
|
const t=e.target;
|
||||||
|
|
@ -238,7 +240,7 @@ document.addEventListener('alpine:init', () => {
|
||||||
if(this.syncEnabled) this.scheduleCloudPush();
|
if(this.syncEnabled) this.scheduleCloudPush();
|
||||||
},
|
},
|
||||||
get syncHeader(){
|
get syncHeader(){
|
||||||
const token = (localStorage.getItem('posimai_token') || '').trim() || (this.syncToken || '').trim();
|
const token = (this.syncToken || '').trim();
|
||||||
return token ? { Authorization: 'Bearer ' + token } : null;
|
return token ? { Authorization: 'Bearer ' + token } : null;
|
||||||
},
|
},
|
||||||
async pullCloudState(){
|
async pullCloudState(){
|
||||||
|
|
@ -303,7 +305,7 @@ document.addEventListener('alpine:init', () => {
|
||||||
},
|
},
|
||||||
scheduleCloudPush(){
|
scheduleCloudPush(){
|
||||||
if(this.syncTimer) clearTimeout(this.syncTimer);
|
if(this.syncTimer) clearTimeout(this.syncTimer);
|
||||||
this.syncTimer = setTimeout(()=>{ this.pushCloudState().catch(()=>{}); }, 800);
|
this.syncTimer = setTimeout(()=>{ this.pushCloudState().catch(e=>{ console.warn('[sc] pushCloudState failed:', e); }); }, 800);
|
||||||
},
|
},
|
||||||
enableSyncWithInput(){
|
enableSyncWithInput(){
|
||||||
const token = (this.syncToken || '').trim();
|
const token = (this.syncToken || '').trim();
|
||||||
|
|
@ -311,9 +313,9 @@ document.addEventListener('alpine:init', () => {
|
||||||
this.syncStatus = '同期キー未設定';
|
this.syncStatus = '同期キー未設定';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
localStorage.setItem(SYNC_KEY_LS, token);
|
sessionStorage.setItem(SYNC_KEY_LS, token);
|
||||||
this.syncEnabled = true;
|
this.syncEnabled = true;
|
||||||
this.pullCloudState().catch(()=>{});
|
this.pullCloudState().catch(e=>{ console.warn('[sc] pullCloudState failed:', e); });
|
||||||
},
|
},
|
||||||
async syncNow(){
|
async syncNow(){
|
||||||
if(!this.syncEnabled){
|
if(!this.syncEnabled){
|
||||||
|
|
@ -393,7 +395,7 @@ document.addEventListener('alpine:init', () => {
|
||||||
isDone(id){ return !!this.progress[id]; },
|
isDone(id){ return !!this.progress[id]; },
|
||||||
markDone(){
|
markDone(){
|
||||||
if(!this.currentUnit) return;
|
if(!this.currentUnit) return;
|
||||||
this.progress[this.currentUnit.id]=true;
|
this.progress = {...this.progress, [this.currentUnit.id]: true};
|
||||||
localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress));
|
localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress));
|
||||||
this.markStateChanged();
|
this.markStateChanged();
|
||||||
},
|
},
|
||||||
|
|
@ -412,7 +414,7 @@ document.addEventListener('alpine:init', () => {
|
||||||
saveScore(id,correct,total){
|
saveScore(id,correct,total){
|
||||||
const prev=this.scores[id];
|
const prev=this.scores[id];
|
||||||
if(!prev||correct>prev.best){
|
if(!prev||correct>prev.best){
|
||||||
this.scores[id]={best:correct,total};
|
this.scores = {...this.scores, [id]: {best:correct, total}};
|
||||||
localStorage.setItem('posimai-sc-scores',JSON.stringify(this.scores));
|
localStorage.setItem('posimai-sc-scores',JSON.stringify(this.scores));
|
||||||
this.markStateChanged();
|
this.markStateChanged();
|
||||||
}
|
}
|
||||||
|
|
@ -446,7 +448,7 @@ document.addEventListener('alpine:init', () => {
|
||||||
this.$nextTick(()=>{
|
this.$nextTick(()=>{
|
||||||
if(window.lucide) lucide.createIcons();
|
if(window.lucide) lucide.createIcons();
|
||||||
const m=document.getElementById('main');
|
const m=document.getElementById('main');
|
||||||
if(m) m.scrollTo({top:0,behavior:'smooth'});
|
if(m){ m.scrollTo({top:0,behavior:'smooth'}); m.focus({preventScroll:true}); }
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -462,7 +464,7 @@ document.addEventListener('alpine:init', () => {
|
||||||
delete this.wrongUnits[uid];
|
delete this.wrongUnits[uid];
|
||||||
// auto-mark done on perfect score
|
// auto-mark done on perfect score
|
||||||
if(!this.progress[uid]){
|
if(!this.progress[uid]){
|
||||||
this.progress[uid]=true;
|
this.progress = {...this.progress, [uid]: true};
|
||||||
try{ localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress)); }catch(e){}
|
try{ localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress)); }catch(e){}
|
||||||
this.markStateChanged();
|
this.markStateChanged();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// posimai-sc SW — same-origin の静的資産のみキャッシュ(CDN は対象外)
|
// posimai-sc SW — same-origin の静的資産のみキャッシュ(CDN は対象外)
|
||||||
const CACHE = 'posimai-sc-v7';
|
const CACHE = 'posimai-sc-v8';
|
||||||
const STATIC = [
|
const STATIC = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|
@ -48,7 +48,12 @@ self.addEventListener('fetch', e => {
|
||||||
cache.match(e.request).then(cached => {
|
cache.match(e.request).then(cached => {
|
||||||
const network = fetch(e.request)
|
const network = fetch(e.request)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.ok && res.type === 'basic') cache.put(e.request, res.clone());
|
// type:'basic' = same-origin only; CORS レスポンスはキャッシュしない
|
||||||
|
if (res.ok && res.type === 'basic') {
|
||||||
|
cache.put(e.request, res.clone()).catch(err => {
|
||||||
|
console.warn('[sc-sw] cache.put failed (quota?):', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.catch(() => cached || new Response('Offline', { status: 503, statusText: 'Service Unavailable' }));
|
.catch(() => cached || new Response('Offline', { status: 503, statusText: 'Service Unavailable' }));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue