nordabiz/templates/admin/zopk_news.html
Maciej Pienczyn 91fea3ba2c security: Fix critical vulnerabilities in ZOP Kaszubia module
- Fix XSS: innerHTML → textContent for modal messages
- Fix XSS: Safe DOM element creation for toast notifications
- Add project_id validation in admin_zopk_news_add
- Add URL protocol validation (allow only http/https)
- Hide exception details from API responses (log instead)
- Add rate limiting (60/min) on public ZOPK routes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 21:07:13 +01:00

560 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}ZOP Kaszubia Newsy - Panel Admina{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.page-header h1 {
font-size: var(--font-size-2xl);
}
.filters {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
}
.filter-btn {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
text-decoration: none;
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: var(--transition);
}
.filter-btn:hover {
background: var(--background);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.news-table-wrapper {
overflow-x: auto;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.news-table {
width: 100%;
min-width: 900px;
}
.news-table th,
.news-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.news-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.news-table tr:hover {
background: var(--background);
}
.news-title {
max-width: 400px;
}
.news-title a {
color: var(--text-primary);
text-decoration: none;
font-weight: 500;
}
.news-title a:hover {
color: var(--primary);
}
.news-title small {
display: block;
color: var(--text-secondary);
font-size: var(--font-size-xs);
margin-top: 2px;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-pending { background: #fef3c7; color: #92400e; }
.status-approved { background: #dcfce7; color: #166534; }
.status-rejected { background: #fee2e2; color: #991b1b; }
.source-badge {
display: inline-block;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
background: #f3f4f6;
color: #374151;
}
.action-btn {
padding: 4px 8px;
font-size: var(--font-size-xs);
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: var(--transition);
}
.action-btn.approve {
background: #28a745;
color: white;
}
.action-btn.reject {
background: #dc3545;
color: white;
}
.action-btn:hover {
opacity: 0.8;
}
.pagination {
display: flex;
justify-content: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-xl);
}
.pagination a,
.pagination span {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
font-weight: 500;
}
.pagination a {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
}
.pagination a:hover {
background: var(--background);
}
.pagination span.current {
background: var(--primary);
color: white;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
background: var(--surface);
border-radius: var(--radius-lg);
}
/* AI Stars Rating */
.ai-stars {
display: inline-flex;
gap: 1px;
font-size: 12px;
}
.ai-stars .star-filled { color: #f59e0b; }
.ai-stars .star-empty { color: #d1d5db; }
.ai-score-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.ai-score-badge.score-5 { background: #dcfce7; color: #166534; }
.ai-score-badge.score-4 { background: #d1fae5; color: #047857; }
.ai-score-badge.score-3 { background: #fef3c7; color: #92400e; }
.ai-score-badge.score-2 { background: #fed7aa; color: #c2410c; }
.ai-score-badge.score-1 { background: #fee2e2; color: #991b1b; }
.ai-score-badge.score-none { background: #f3f4f6; color: #6b7280; }
/* Sortable Headers */
.sortable-header {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.sortable-header a {
color: var(--text-secondary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sortable-header a:hover {
color: var(--primary);
}
.sortable-header.active a {
color: var(--primary);
font-weight: 700;
}
.sort-icon {
font-size: 10px;
opacity: 0.5;
}
.sortable-header.active .sort-icon {
opacity: 1;
}
/* Sort Controls */
.sort-controls {
display: flex;
gap: var(--spacing-md);
align-items: center;
margin-left: auto;
padding-left: var(--spacing-lg);
border-left: 1px solid var(--border);
}
.sort-controls select {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
font-size: var(--font-size-sm);
color: var(--text-primary);
}
@media (max-width: 768px) {
.news-table {
display: block;
overflow-x: auto;
}
.sort-controls {
margin-left: 0;
padding-left: 0;
border-left: none;
width: 100%;
margin-top: var(--spacing-md);
}
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Zarządzanie newsami ZOP Kaszubia</h1>
<p class="text-muted">{{ total }} artykułów</p>
</div>
<a href="{{ url_for('admin_zopk') }}" class="btn btn-secondary">Powrót do dashboardu</a>
</div>
<div class="filters">
<span class="text-muted">Status:</span>
<a href="{{ url_for('admin_zopk_news', status='all', sort=current_sort, dir=current_dir) }}" class="filter-btn {% if current_status == 'all' %}active{% endif %}">Wszystkie</a>
<a href="{{ url_for('admin_zopk_news', status='pending', sort=current_sort, dir=current_dir) }}" class="filter-btn {% if current_status == 'pending' %}active{% endif %}">Oczekujące</a>
<a href="{{ url_for('admin_zopk_news', status='approved', sort=current_sort, dir=current_dir) }}" class="filter-btn {% if current_status == 'approved' %}active{% endif %}">Zatwierdzone</a>
<a href="{{ url_for('admin_zopk_news', status='rejected', sort=current_sort, dir=current_dir) }}" class="filter-btn {% if current_status == 'rejected' %}active{% endif %}">Odrzucone</a>
<div class="sort-controls">
<span class="text-muted">Sortuj:</span>
<select id="sort-select" onchange="updateSort()">
<option value="date-desc" {% if current_sort == 'date' and current_dir == 'desc' %}selected{% endif %}>Data (najnowsze)</option>
<option value="date-asc" {% if current_sort == 'date' and current_dir == 'asc' %}selected{% endif %}>Data (najstarsze)</option>
<option value="score-desc" {% if current_sort == 'score' and current_dir == 'desc' %}selected{% endif %}>Ocena AI (najwyższa)</option>
<option value="score-asc" {% if current_sort == 'score' and current_dir == 'asc' %}selected{% endif %}>Ocena AI (najniższa)</option>
<option value="title-asc" {% if current_sort == 'title' and current_dir == 'asc' %}selected{% endif %}>Tytuł (A-Z)</option>
<option value="title-desc" {% if current_sort == 'title' and current_dir == 'desc' %}selected{% endif %}>Tytuł (Z-A)</option>
</select>
</div>
</div>
{% if news_items %}
<div class="news-table-wrapper">
<table class="news-table">
<thead>
<tr>
<th class="sortable-header {% if current_sort == 'title' %}active{% endif %}" style="width: 35%">
<a href="{{ url_for('admin_zopk_news', status=current_status, sort='title', dir='desc' if current_sort == 'title' and current_dir == 'asc' else 'asc') }}">
Tytuł
<span class="sort-icon">{% if current_sort == 'title' %}{{ '▲' if current_dir == 'asc' else '▼' }}{% else %}⇅{% endif %}</span>
</a>
</th>
<th>Źródło</th>
<th class="sortable-header {% if current_sort == 'score' %}active{% endif %}">
<a href="{{ url_for('admin_zopk_news', status=current_status, sort='score', dir='asc' if current_sort == 'score' and current_dir == 'desc' else 'desc') }}">
Ocena AI
<span class="sort-icon">{% if current_sort == 'score' %}{{ '▲' if current_dir == 'asc' else '▼' }}{% else %}⇅{% endif %}</span>
</a>
</th>
<th>Status</th>
<th class="sortable-header {% if current_sort == 'date' %}active{% endif %}">
<a href="{{ url_for('admin_zopk_news', status=current_status, sort='date', dir='asc' if current_sort == 'date' and current_dir == 'desc' else 'desc') }}">
Data
<span class="sort-icon">{% if current_sort == 'date' %}{{ '▲' if current_dir == 'asc' else '▼' }}{% else %}⇅{% endif %}</span>
</a>
</th>
<th style="min-width: 150px;">Akcje</th>
</tr>
</thead>
<tbody>
{% for news in news_items %}
<tr id="news-row-{{ news.id }}">
<td class="news-title">
<a href="{{ news.url }}" target="_blank" rel="noopener">{{ news.title }}</a>
<small>{{ news.source_name or news.source_domain }}</small>
</td>
<td><span class="source-badge">{{ news.source_type }}</span></td>
<td>
{% if news.ai_relevance_score %}
<span class="ai-score-badge score-{{ news.ai_relevance_score }}" title="{{ news.ai_evaluation_reason or 'Brak opisu' }}">
<span class="ai-stars">
{% for i in range(1, 6) %}
<span class="{{ 'star-filled' if i <= news.ai_relevance_score else 'star-empty' }}"></span>
{% endfor %}
</span>
</span>
{% elif news.ai_relevant is not none %}
<span class="ai-score-badge score-{{ '3' if news.ai_relevant else '1' }}" title="{{ news.ai_evaluation_reason or 'Brak opisu' }}">
{{ '✓ Relevant' if news.ai_relevant else '✗ Nie' }}
</span>
{% else %}
<span class="ai-score-badge score-none"></span>
{% endif %}
</td>
<td>
<span class="status-badge status-{{ news.status }}">
{% if news.status == 'pending' %}Oczekuje{% elif news.status == 'approved' %}Zatwierdzony{% else %}Odrzucony{% endif %}
</span>
</td>
<td>{{ news.published_at.strftime('%d.%m.%Y') if news.published_at else (news.created_at.strftime('%d.%m.%Y') if news.created_at else '-') }}</td>
<td style="white-space: nowrap;">
{% if news.status == 'pending' %}
<button class="action-btn approve" onclick="approveNews({{ news.id }})">Zatwierdź</button>
<button class="action-btn reject" onclick="rejectNews({{ news.id }})">Odrzuć</button>
{% elif news.status == 'approved' %}
<button class="action-btn reject" onclick="rejectNews({{ news.id }})">Odrzuć</button>
{% else %}
<button class="action-btn approve" onclick="approveNews({{ news.id }})">Przywróć</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<nav class="pagination">
{% if page > 1 %}
<a href="{{ url_for('admin_zopk_news', page=page-1, status=current_status, sort=current_sort, dir=current_dir) }}">&laquo; Poprzednia</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
<a href="{{ url_for('admin_zopk_news', page=p, status=current_status, sort=current_sort, dir=current_dir) }}">{{ p }}</a>
{% elif p == 4 or p == total_pages - 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('admin_zopk_news', page=page+1, status=current_status, sort=current_sort, dir=current_dir) }}">Następna &raquo;</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">
<p>Brak artykułów o tym statusie.</p>
</div>
{% endif %}
<!-- Universal Confirm/Alert Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px;">
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
<div class="modal-icon" id="confirmModalIcon"></div>
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
<p class="modal-description" id="confirmModalMessage">Czy na pewno chcesz kontynuować?</p>
</div>
<div class="form-group" id="confirmModalInputGroup" style="display: none;">
<label id="confirmModalInputLabel">Wprowadź wartość:</label>
<input type="text" id="confirmModalInput" placeholder="">
</div>
<div class="modal-actions" style="justify-content: center;">
<button type="button" class="btn btn-secondary" id="confirmModalCancel">Anuluj</button>
<button type="button" class="btn btn-primary" id="confirmModalOk">OK</button>
</div>
</div>
</div>
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<style>
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center; }
.modal-overlay.active { display: flex; }
.modal { background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl); max-width: 500px; width: 90%; }
.modal-icon { font-size: 3em; margin-bottom: var(--spacing-md); }
.modal-actions { display: flex; justify-content: flex-end; gap: var(--spacing-sm); margin-top: var(--spacing-lg); }
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: slideIn 0.3s ease; max-width: 350px; }
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--error); }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
</style>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
// Universal Modal System
let confirmModalResolve = null;
function showConfirm(message, options = {}) {
return new Promise((resolve) => {
confirmModalResolve = resolve;
const modal = document.getElementById('confirmModal');
document.getElementById('confirmModalIcon').textContent = options.icon || '❓';
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
document.getElementById('confirmModalMessage').textContent = message;
document.getElementById('confirmModalCancel').textContent = options.cancelText || 'Anuluj';
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
document.getElementById('confirmModalOk').className = 'btn ' + (options.okClass || 'btn-primary');
const inputGroup = document.getElementById('confirmModalInputGroup');
if (options.showInput) {
inputGroup.style.display = 'block';
document.getElementById('confirmModalInputLabel').textContent = options.inputLabel || 'Wprowadź wartość:';
document.getElementById('confirmModalInput').value = '';
document.getElementById('confirmModalInput').placeholder = options.inputPlaceholder || '';
} else {
inputGroup.style.display = 'none';
}
document.getElementById('confirmModalCancel').style.display = options.alertOnly ? 'none' : '';
modal.classList.add('active');
});
}
function closeConfirmModal(result) {
document.getElementById('confirmModal').classList.remove('active');
if (confirmModalResolve) { confirmModalResolve(result); confirmModalResolve = null; }
}
document.getElementById('confirmModalOk').addEventListener('click', () => {
const inputGroup = document.getElementById('confirmModalInputGroup');
closeConfirmModal(inputGroup.style.display !== 'none' ? document.getElementById('confirmModalInput').value : true);
});
document.getElementById('confirmModalCancel').addEventListener('click', () => closeConfirmModal(false));
document.getElementById('confirmModal').addEventListener('click', (e) => { if (e.target.id === 'confirmModal') closeConfirmModal(false); });
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
const toast = document.createElement('div');
toast.className = `toast ${type}`;
// Bezpieczne tworzenie elementów - unikamy innerHTML z danymi użytkownika
const iconSpan = document.createElement('span');
iconSpan.style.fontSize = '1.2em';
iconSpan.textContent = icons[type] || icons.info;
const msgSpan = document.createElement('span');
msgSpan.textContent = message;
toast.appendChild(iconSpan);
toast.appendChild(msgSpan);
container.appendChild(toast);
setTimeout(() => { toast.style.animation = 'slideOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
}
function updateSort() {
const select = document.getElementById('sort-select');
const [sort, dir] = select.value.split('-');
const url = new URL(window.location);
url.searchParams.set('sort', sort);
url.searchParams.set('dir', dir);
url.searchParams.delete('page'); // Reset to first page
window.location.href = url.toString();
}
async function approveNews(newsId) {
try {
const response = await fetch(`/admin/zopk/news/${newsId}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showToast('News został zatwierdzony', 'success');
setTimeout(() => location.reload(), 800);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
async function rejectNews(newsId) {
const reason = await showConfirm('Podaj powód odrzucenia (opcjonalnie):', {
icon: '✕',
title: 'Odrzuć news',
showInput: true,
inputLabel: 'Powód odrzucenia:',
inputPlaceholder: 'np. Nieistotny artykuł...',
okText: 'Odrzuć',
okClass: 'btn-danger'
});
if (reason === false) return;
try {
const response = await fetch(`/admin/zopk/news/${newsId}/reject`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ reason: reason })
});
const data = await response.json();
if (data.success) {
showToast('News został odrzucony', 'success');
setTimeout(() => location.reload(), 800);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia: ' + error.message, 'error');
}
}
{% endblock %}