This commit is contained in:
th committed 2026-04-06 20:27:19 +02:00
1 parent e85b5203cd
commit 0d4d369870
19 files changed
+2258 -1814

No files matched your search

+157 -46
View File
@@ -3,8 +3,29 @@
require_once __DIR__ . '/../src/bootstrap.php';
$db = Database::connect();
// ── Security headers ──
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
// ── Session ──
session_set_cookie_params([
'httponly' => true,
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'samesite' => 'Strict',
]);
session_start();
// CSRF token
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// ── Auth ──
$method = $_SERVER['REQUEST_METHOD'];
@@ -23,7 +44,7 @@ if (str_starts_with($route, '/api/auth')) {
// GET /api/auth/status
if ($route === '/api/auth/status' && $method === 'GET') {
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn]);
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token']]);
exit;
}
@@ -38,19 +59,34 @@ if (str_starts_with($route, '/api/auth')) {
}
$hash = password_hash($pw, PASSWORD_BCRYPT);
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('password_hash', :hash)")->execute(['hash' => $hash]);
session_regenerate_id(true);
$_SESSION['hyprcrd_auth'] = true;
echo json_encode(['ok' => true]);
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
exit;
}
// POST /api/auth/login
// POST /api/auth/login (rate limited)
if ($route === '/api/auth/login' && $method === 'POST') {
$attempts = $_SESSION['login_attempts'] ?? 0;
$lastAttempt = $_SESSION['login_last'] ?? 0;
if ($attempts >= 5 && time() - $lastAttempt < 60) {
http_response_code(429);
echo json_encode(['error' => 'too many attempts, wait 60s']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$pw = $input['password'] ?? '';
if ($pwHash && password_verify($pw, $pwHash)) {
session_regenerate_id(true);
$_SESSION['hyprcrd_auth'] = true;
echo json_encode(['ok' => true]);
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$_SESSION['login_attempts'] = 0;
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
} else {
$_SESSION['login_attempts'] = $attempts + 1;
$_SESSION['login_last'] = time();
usleep(300000); // 300ms delay against timing attacks
http_response_code(401);
echo json_encode(['error' => 'wrong password']);
}
@@ -85,6 +121,20 @@ if (str_starts_with($route, '/api/')) {
error_reporting(0);
header('Content-Type: application/json');
// CSRF check on all state-changing requests
if (in_array($method, ['POST', 'PATCH', 'DELETE'])) {
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfHeader)) {
http_response_code(403);
echo json_encode(['error' => 'invalid csrf token']);
exit;
}
}
// Input sanitization
function validColor($c) { return preg_match('/^#[0-9a-fA-F]{6}$/', $c ?? '') ? $c : '#ffffff'; }
function validInt($v, $default = 0) { return is_numeric($v) ? (int)$v : $default; }
// Cards
if ($route === '/api/cards' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
@@ -92,7 +142,7 @@ if (str_starts_with($route, '/api/')) {
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
$pos = $input['position'] ?? 0;
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position) VALUES (:title, :body, :color, :bucket_id, :position)')->execute([
'title' => $title, 'body' => $input['body'] ?? '', 'color' => $input['color'] ?? '#ffffff', 'bucket_id' => $input['bucket_id'] ?? null, 'position' => $pos,
'title' => $title, 'body' => $input['body'] ?? '', 'color' => validColor($input['color'] ?? null), 'bucket_id' => validInt($input['bucket_id'] ?? null, null), 'position' => validInt($pos),
]);
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
exit;
@@ -135,7 +185,7 @@ if (str_starts_with($route, '/api/')) {
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
$fields = []; $params = ['id' => $m[1]];
foreach (['name', 'position'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; } }
foreach (['name', 'position', 'color'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } }
if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
$b = $db->prepare('SELECT * FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); echo json_encode($b->fetch()); exit;
}
@@ -146,31 +196,68 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['ok' => true]); exit;
}
// Upload
// List all upload files
if ($route === '/api/uploads' && $method === 'GET') {
$dir = __DIR__ . '/uploads';
$files = [];
if (is_dir($dir)) {
foreach (glob($dir . '/*.{png,jpg,gif,webp}', GLOB_BRACE) as $f) {
$name = basename($f);
$files[] = ['name' => $name, 'url' => 'uploads/' . $name, 'size' => filesize($f), 'modified' => filemtime($f)];
}
}
usort($files, fn($a, $b) => $b['modified'] - $a['modified']);
echo json_encode($files);
exit;
}
// Delete an upload file
if (preg_match('#^/api/uploads/([a-f0-9]+\.\w+)$#', $route, $m) && $method === 'DELETE') {
$file = __DIR__ . '/uploads/' . basename($m[1]);
if (is_file($file)) {
unlink($file);
echo json_encode(['ok' => true]);
} else {
http_response_code(404);
echo json_encode(['error' => 'file not found']);
}
exit;
}
// Upload (max 10MB, real MIME check)
if ($route === '/api/upload' && $method === 'POST') {
$uploadDir = __DIR__ . '/uploads';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => 'no file', 'files' => array_keys($_FILES), 'err' => $_FILES['file']['error'] ?? 'missing']);
echo json_encode(['error' => 'no file', 'debug_files' => array_keys($_FILES), 'debug_method' => $method, 'debug_ct' => $_SERVER['CONTENT_TYPE'] ?? 'none']);
exit;
}
$file = $_FILES['file'];
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/svg+xml' => 'svg'];
$ext = $allowed[$file['type']] ?? null;
if (!$ext) {
if ($file['size'] > 10 * 1024 * 1024) {
http_response_code(400);
echo json_encode(['error' => 'invalid file type: ' . $file['type']]);
echo json_encode(['error' => 'file too large (max 10MB)']);
exit;
}
$name = bin2hex(random_bytes(8)) . '.' . $ext;
// Check real MIME via finfo, not client-supplied type
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($file['tmp_name']);
$ext = $allowed[$realMime] ?? null;
if (!$ext) {
http_response_code(400);
echo json_encode(['error' => 'invalid image type']);
exit;
}
$name = bin2hex(random_bytes(16)) . '.' . $ext;
$dest = $uploadDir . '/' . $name;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['error' => 'failed to save, check permissions on ' . $uploadDir]);
echo json_encode(['error' => 'upload failed']);
exit;
}
@@ -289,6 +376,7 @@ if (str_starts_with($route, '/api/')) {
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/@phosphor-icons/web"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Silkscreen:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=<?= time() ?>">
@@ -299,14 +387,14 @@ if (str_starts_with($route, '/api/')) {
}}}
</script>
<script>
const themes = {
window.themes = {
slate: { label:'Slate', vars:{ '--t-bg':'#1e293b','--t-surface':'#273349','--t-raised':'#2e3d56','--t-border':'#384a64','--t-dim':'#64748b','--t-muted':'#94a3b8','--t-text':'#cbd5e1','--t-bright':'#f1f5f9','--t-accent':'#38bdf8','--t-accent2':'#818cf8','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.04)','--t-overlay':'rgba(15,23,42,0.75)','--t-font-pixel':'"Silkscreen", cursive' }},
phosphor: { label:'Phosphor', vars:{ '--t-bg':'#0a0f0a','--t-surface':'#0f1a0f','--t-raised':'#142214','--t-border':'#1a3a1a','--t-dim':'#2d5a2d','--t-muted':'#4a8a4a','--t-text':'#7bc67b','--t-bright':'#b8f0b8','--t-accent':'#4ade80','--t-accent2':'#22d3ee','--t-danger':'#f87171','--t-scanline':'rgba(0,0,0,0.1)','--t-overlay':'rgba(5,10,5,0.85)','--t-font-pixel':'"Silkscreen", cursive' }},
midnight: { label:'Midnight', vars:{ '--t-bg':'#0f172a','--t-surface':'#162033','--t-raised':'#1c2940','--t-border':'#263650','--t-dim':'#475882','--t-muted':'#7088b0','--t-text':'#a8bdd8','--t-bright':'#e2e8f0','--t-accent':'#a78bfa','--t-accent2':'#f472b6','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.06)','--t-overlay':'rgba(10,15,30,0.8)','--t-font-pixel':'"Silkscreen", cursive' }},
amber: { label:'Amber', vars:{ '--t-bg':'#1a1408','--t-surface':'#231c0e','--t-raised':'#2c2414','--t-border':'#3d3520','--t-dim':'#6b5c38','--t-muted':'#a08c5a','--t-text':'#d4c088','--t-bright':'#fbe8b0','--t-accent':'#fbbf24','--t-accent2':'#fb923c','--t-danger':'#ef4444','--t-scanline':'rgba(0,0,0,0.08)','--t-overlay':'rgba(20,16,5,0.8)','--t-font-pixel':'"Silkscreen", cursive' }},
paper: { label:'Paper', vars:{ '--t-bg':'#f5f1eb','--t-surface':'#ffffff','--t-raised':'#eae5dd','--t-border':'#d6d0c4','--t-dim':'#a8a08e','--t-muted':'#7a7468','--t-text':'#3d3832','--t-bright':'#1a1714','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0)','--t-overlay':'rgba(245,241,235,0.8)','--t-font-pixel':'"JetBrains Mono", monospace' }},
};
function applyTheme(name) {
window.applyTheme = function(name) {
const t = themes[name] || themes.slate;
for (const [k,v] of Object.entries(t.vars)) document.documentElement.style.setProperty(k,v);
localStorage.setItem('hyprcrd-theme', name);
@@ -321,44 +409,48 @@ if (str_starts_with($route, '/api/')) {
<!-- tailwind safelist: border-t-bright border-transparent scale-125 text-t-accent line-clamp-2 border-t-danger text-t-danger pr-16 -->
<body class="bg-t-bg text-t-text min-h-screen transition-colors duration-200 flex flex-col">
<header class="flex items-center justify-between px-6 py-4 border-b border-t-border">
<div class="flex items-center gap-3">
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
<h1 class="font-pixel text-t-accent text-lg tracking-wider">hyprcrd</h1>
<span class="text-[10px] text-t-dim font-pixel ml-1">v0.1</span>
<header id="main-header" class="sticky top-0 z-40 bg-t-bg border-b border-t-border shadow-[0_4px_20px_-4px_rgba(0,0,0,0.3)]" style="overflow:hidden;">
<div id="header-tint" class="absolute inset-0 pointer-events-none" style="opacity:0;transition:opacity 0.3s,background 0.3s;z-index:0;"></div>
<div class="flex items-center justify-between px-6 py-3 relative z-[1]">
<div class="flex items-center gap-3">
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
<h1 class="font-pixel text-t-accent text-lg tracking-wider">hyprcrd</h1>
<span class="text-[10px] text-t-dim font-pixel ml-1">v0.1</span>
</div>
<div class="flex items-center gap-3">
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">search</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
</button>
<button id="tasks-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">tasks</span>
<span id="tasks-open-count" class="font-pixel text-[9px] px-1.5 py-0.5 border border-t-border rounded text-t-dim">0</span>
</button>
<button id="add-btn" class="flex items-center gap-2 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
<i class="ph ph-plus-square text-sm"></i><span class="font-pixel text-[11px]">new_card</span>
</button>
</div>
</div>
<div class="flex items-center gap-3">
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">search</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
</button>
<button id="tasks-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">tasks</span>
<span id="tasks-open-count" class="font-pixel text-[9px] px-1.5 py-0.5 border border-t-border rounded text-t-dim">0</span>
</button>
<button id="add-btn" class="flex items-center gap-2 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
<i class="ph ph-plus-square text-sm"></i><span class="font-pixel text-[11px]">new_card</span>
<div class="flex items-center px-6 pb-2 relative z-[1]">
<div class="flex items-center gap-1 overflow-x-auto flex-1" id="bucket-bar"></div>
<button id="sort-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2">
<i class="ph ph-sort-ascending text-xs"></i>
<span id="sort-label">manual</span>
</button>
</div>
<div id="group-bar" class="hidden flex items-center gap-2 px-6 pb-2 relative z-[1]"></div>
</header>
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pt-5 pb-24"></div>
<div class="flex items-center gap-1 px-6 pt-4 pb-2 overflow-x-auto" id="bucket-bar"></div>
<div class="flex items-center justify-between px-6 pb-2">
<div></div>
<button id="sort-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors">
<i class="ph ph-sort-ascending text-xs"></i>
<span id="sort-label">manual</span>
</button>
</div>
<div id="group-bar" class="hidden flex items-center gap-2 px-6 pb-2"></div>
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pb-24"></div>
<footer class="fixed inset-x-0 bottom-0 z-40 border-t border-t-border bg-t-bg/95 backdrop-blur-sm px-6 py-2 flex items-center justify-between gap-4">
<footer class="fixed inset-x-0 bottom-0 z-40 border-t border-t-border bg-t-bg shadow-[0_-4px_20px_-4px_rgba(0,0,0,0.3)] px-6 py-2 flex items-center justify-between gap-4">
<div class="flex items-center gap-2 flex-wrap">
<input type="file" id="import-file" accept=".zip,application/zip" class="hidden">
<button id="import-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">import</span>
</button>
<button id="gallery-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-images text-sm"></i><span class="font-pixel text-[10px]">images</span>
</button>
<button id="export-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
</button>
@@ -471,13 +563,16 @@ if (str_starts_with($route, '/api/')) {
<!-- Main content -->
<div class="flex-1 flex flex-col min-w-0">
<div class="card-bar w-full shrink-0" id="detail-color"></div>
<div class="absolute top-4 right-4 flex gap-2 z-10">
<div id="detail-controls" class="absolute top-4 right-4 flex gap-2 z-10">
<button id="detail-edit" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span>
</button>
<button id="detail-delete" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">del</span>
</button>
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="zen mode">
<i class="ph ph-corners-out text-sm"></i>
</button>
<button id="detail-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
@@ -497,6 +592,22 @@ if (str_starts_with($route, '/api/')) {
<div id="link-ac" class="fixed z-[80] bg-t-surface border border-t-border shadow-xl max-h-48 overflow-y-auto hidden" style="min-width:220px;max-width:360px;"></div>
<!-- Gallery -->
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
<i class="ph ph-images text-t-accent text-base"></i>
<h2 class="font-pixel text-xs text-t-accent">images</h2>
<span id="gallery-count" class="font-pixel text-[9px] text-t-dim"></span>
<div class="flex-1"></div>
<button id="gallery-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
</div>
<div id="gallery-grid" class="flex-1 overflow-y-auto p-4 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 content-start"></div>
</div>
</div>
<!-- Context menu -->
<div id="ctx-menu" class="fixed z-[85] bg-t-surface border border-t-border shadow-2xl hidden" style="min-width:180px;">
<div id="ctx-items" class="py-1"></div>
@@ -550,6 +661,6 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
<script>document.write('<script src="js/app.js?v=' + Date.now() + '"><\/script>');</script>
<script type="module" src="js/app.js?v=<?= time() ?>"></script>
</body>
</html>