- ContentSecurityPolicyMiddleware : nonce aléatoire par requête dans la
ContextVar `CSP_NONCE` (posée avant `call_next` → visible des templates),
`script-src 'self' 'unsafe-eval' 'nonce-…'` — plus aucun script inline
sans nonce ne tourne (fin des XSS injectés en JS)
- 38 tags `<script>` des templates : `nonce="{{ csp_nonce() }}"` (passage
scripté, vérifié : 0 restant) ; `LOCAL_LOGIN_HTML` (constante de module) :
helper `_with_nonce()` au rendu ; collections.py : 3 scripts Python
(chart/form/map) noncés
- `<meta name="htmx-config" content='{"inlineScriptNonce": …}'>` dans base.html
: htmx ré-injecte les <script> des réponses boostées avec le bon nonce
- `script-src-attr 'unsafe-inline'` : les 74 handlers `onclick=` inline
restent couverts (le nonce les aurait désactivés aussi)
- chart.js (cdn.jsdelivr.net) et leaflet (unpkg) ajoutés à script-src/style-src
: vues chart/map déjà BLOQUÉES par la CSP depuis toujours
(commentaire ponytail: upgrade = vendoriser puis retirer les hôtes)
- reste d'A20 : unsafe-eval (Alpine x-data → @alpinejs/csp), externalisation
JS (A27), resserrer img-src/connect-src
test : test_csp_nonce_per_request (page base.html + page hors template,
nonce unique par requête)
suite **1037/1037** · `ruff check app tests` OK · docs à jour
149 lines
6.2 KiB
HTML
149 lines
6.2 KiB
HTML
{# Card detail modal content — full issue info + comments #}
|
|
<div class="card-detail" x-data="cardDetail()">
|
|
<!-- Title -->
|
|
<div style="display:flex; align-items:flex-start; gap:12px; margin-bottom:16px;">
|
|
<span style="font-size:24px;">{{ issue.icon }}</span>
|
|
<div style="flex:1;">
|
|
<h2 style="font-size:20px; font-weight:700; color:var(--text-primary); margin:0;"
|
|
contenteditable="true"
|
|
@blur="updateField('title', $event.target.textContent)">{{ issue.title }}</h2>
|
|
<div style="font-size:12px; color:var(--text-dim); margin-top:4px;">
|
|
#{{ issue.id }} · opened by {{ issue.author }} · {{ issue.created }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Properties -->
|
|
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;">
|
|
<!-- Status -->
|
|
<div class="filter-pill" style="cursor:pointer;" @click="toggleStatusMenu()">
|
|
<span class="legend-dot" style="width:8px; height:8px; background:{{ issue.status_color }};"></span>
|
|
{{ issue.status_label }}
|
|
<span style="font-size:10px;">▾</span>
|
|
</div>
|
|
<!-- Assignee -->
|
|
<div class="filter-pill" style="cursor:pointer;">
|
|
<span>{{ fd_icon("user",14) }}</span>
|
|
{% if issue.assignee %}{{ issue.assignee }}{% else %}Unassigned{% endif %}
|
|
<span style="font-size:10px;">▾</span>
|
|
</div>
|
|
<!-- Deadline -->
|
|
<div class="filter-pill" style="cursor:pointer;">
|
|
<span>{{ fd_icon("calendar",14) }}</span>
|
|
{% if issue.due_date %}{{ issue.due_date }}{% else %}No deadline{% endif %}
|
|
</div>
|
|
<!-- Labels -->
|
|
{% for label in issue.labels %}
|
|
<div class="filter-pill">
|
|
<span class="legend-dot" style="width:8px; height:8px; background:#{{ label.color }};"></span>
|
|
{{ label.name }}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Body / Description -->
|
|
<div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;"
|
|
contenteditable="true"
|
|
@blur="updateField('body', $event.target.innerHTML)">
|
|
{% if issue.body %}{{ issue.body }}{% else %}<span style="color:var(--text-dim);">Add description...</span>{% endif %}
|
|
</div>
|
|
|
|
<!-- Checklists -->
|
|
{% for cl in checklists %}
|
|
<div style="margin-bottom:16px;">
|
|
<div style="font-weight:600; font-size:14px; margin-bottom:8px;">☑ {{ cl.title }}</div>
|
|
{% for item in cl.items %}
|
|
<div style="display:flex; align-items:center; gap:8px; padding:4px 0;">
|
|
<input type="checkbox" {% if item.checked %}checked{% endif %}
|
|
@change="toggleChecklistItem({{ item.id }}, $event.target.checked)"
|
|
style="accent-color:var(--blue); width:16px; height:16px;">
|
|
<span style="font-size:13px; {% if item.checked %}text-decoration:line-through; color:var(--text-dim);{% endif %}">
|
|
{{ item.content }}
|
|
</span>
|
|
</div>
|
|
{% endfor %}
|
|
<button class="kanban-new-card" style="margin-top:4px;"
|
|
@click="addChecklistItem({{ cl.id }})">+ Add item</button>
|
|
</div>
|
|
{% endfor %}
|
|
<button class="kanban-new-card" @click="addChecklist()">+ Add checklist</button>
|
|
|
|
<!-- Comments -->
|
|
<div style="border-top:1px solid var(--border); padding-top:16px; margin-top:16px;">
|
|
<h3 style="font-size:14px; font-weight:600; margin-bottom:12px;">Comments ({{ comments|length }})</h3>
|
|
{% for comment in comments %}
|
|
<div style="display:flex; gap:10px; margin-bottom:12px; padding:8px; border-radius:6px;">
|
|
<div class="card-avatar" style="width:24px; height:24px; font-size:11px; flex-shrink:0;">
|
|
{{ comment.user.login[0] if comment.user else '?' }}
|
|
</div>
|
|
<div style="flex:1;">
|
|
<div style="font-size:12px; font-weight:600; margin-bottom:2px;">
|
|
{{ comment.user.login if comment.user else 'Unknown' }}
|
|
<span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span>
|
|
</div>
|
|
<div style="font-size:13px; color:var(--text-primary); line-height:1.5;">
|
|
{{ comment.body }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% if not comments %}
|
|
<p class="text-dim" style="font-size:13px;">No comments yet.</p>
|
|
{% endif %}
|
|
<textarea placeholder="Add a comment..."
|
|
style="width:100%; min-height:60px; background:var(--bg-secondary); border:1px solid var(--border);
|
|
border-radius:6px; padding:8px; color:var(--text-primary); font-family:inherit; font-size:13px;
|
|
resize:vertical; margin-top:8px;"
|
|
@keydown.ctrl.enter="addComment($event.target.value); $event.target.value=''"></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<script nonce="{{ csp_nonce() }}">
|
|
// ponytail: CSRF helper
|
|
function getCsrf() {
|
|
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
|
return m ? m[1] : '';
|
|
}
|
|
|
|
function cardDetail() {
|
|
return {
|
|
updateField(field, value) {
|
|
console.log('Update', field, ':', value);
|
|
// TODO: PATCH /api/issues/{owner}/{repo}/{issue_id}
|
|
},
|
|
toggleStatusMenu() {
|
|
console.log('Toggle status');
|
|
},
|
|
toggleChecklistItem(id, checked) {
|
|
fetch(`/api/checklist-items/${id}?checked=${checked}`, {
|
|
method: 'PATCH',
|
|
headers: { 'X-CSRF-Token': getCsrf() }
|
|
});
|
|
},
|
|
addChecklistItem(clId) {
|
|
const content = prompt('Item name:');
|
|
if (content) {
|
|
fetch(`/api/checklist-items/${owner}/${repo}/${issue_id}/${clId}?content=${encodeURIComponent(content)}`, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': getCsrf() }
|
|
})
|
|
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
|
}
|
|
},
|
|
addChecklist() {
|
|
const title = prompt('Checklist name:') || 'Checklist';
|
|
fetch(`/api/checklists/${owner}/${repo}/${issue_id}?title=${encodeURIComponent(title)}`, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': getCsrf() }
|
|
})
|
|
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
|
},
|
|
addComment(body) {
|
|
if (!body.trim()) return;
|
|
console.log('Add comment:', body);
|
|
// TODO: POST to Gitea API for comments
|
|
}
|
|
};
|
|
}
|
|
</script>
|