Files
flowdeck/app/templates/trash.html
T
bruno c718fe06de
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
fix: A20 (partiel) — CSP nonce par requête, unsafe-inline sort de script-src (v7.7.0)
- 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
2026-10-01 10:41:39 -04:00

97 lines
3.9 KiB
HTML

{% extends "base.html" %}
{% block page_icon %}{{ fd_icon("trash",18) }}{% endblock %}
{% block page_title %}Trash{% endblock %}
{% block title_prefix %}Trash{% endblock %}
{% block content %}
<div class="page-title-area">
<div class="page-title">
<span class="page-icon-lg">{{ fd_icon("trash",24) }}</span>
<h1>Trash</h1>
</div>
</div>
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
<!-- Search -->
<div style="position:relative; margin-bottom:12px;">
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
<input type="text" placeholder="Search pages in Trash" x-model="search"
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
</div>
<!-- Filters -->
<div style="display:flex; gap:8px; margin-bottom:16px;">
<button class="trash-filter active">
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
</button>
<button class="trash-filter">
<span>{{ fd_icon("folder",14) }}</span> In ▾
</button>
</div>
<!-- Items -->
<div style="min-height:200px;">
<template x-for="item in filteredItems" :key="item.id">
<div class="trash-item">
<span class="trash-item-icon" x-text="item.icon"></span>
<div class="trash-item-info">
<span class="trash-item-name" x-text="item.name"></span>
<span class="trash-item-path" x-text="item.path"></span>
</div>
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
↩️
</button>
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
{{ fd_icon("trash",14) }}
</button>
</div>
</template>
<div x-show="filteredItems.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div>
<h3>Trash is empty</h3>
<p>Deleted pages will appear here for 30 days.</p>
</div>
</div>
<!-- Info -->
<div style="padding:12px 16px; margin-top:16px; background:var(--bg-tertiary); border-radius:8px; font-size:13px; color:var(--text-dim); display:flex; align-items:center; gap:8px;">
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
<span style="font-size:16px;">ⓘ</span>
</div>
</div>
{% endblock %}
{% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function trashData() {
return {
search: '',
items: [],
async init() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const token = csrf ? csrf[1] : '';
try {
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
this.items = await r.json();
} catch(e) { this.items = []; }
},
get filteredItems() {
const q = this.search.toLowerCase();
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
},
async restore(id) {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
},
async deleteForever(id) {
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
}
};
}
</script>
{% endblock %}