diff --git a/public/css/style.css b/public/css/style.css
index f6c7728..4a6072a 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -107,7 +107,7 @@ body::after {
}
.group:hover .card-thumb { border-color: var(--t-muted); }
.card-glow:hover { box-shadow: 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
-.card-dragging { opacity: 0.3; transform: scale(0.95); }
+.card-dragging { opacity: 0.15; transform: scale(0.95); }
/* Drop indicators — thick glowing bar */
.card-drop-before::before,
@@ -130,6 +130,56 @@ body::after {
transition: transform 0.2s ease, opacity 0.2s ease;
}
+/* Zen mode */
+html.detail-zen-open,
+body.detail-zen-open { overflow: hidden; }
+#detail.zen > div { inset: 0 !important; border: none !important; border-radius: 0; overflow-y: auto; }
+#detail.zen #detail-sidebar { display: none !important; }
+#detail.zen .card-bar { display: none; }
+#detail.zen #detail-scroll {
+ max-width: 680px;
+ margin: 0 auto;
+ padding: 3rem 2rem;
+ overflow: visible !important;
+}
+#detail.zen #detail-scroll #detail-title { font-size: 1.6rem; margin-bottom: 1.5rem; }
+#detail.zen #detail-scroll #detail-body { font-size: 1rem; line-height: 1.85; }
+#detail.zen #detail-scroll #detail-meta { display: none; }
+#detail.zen #detail-backlinks { display: none; }
+#detail.zen #detail-controls {
+ position: fixed !important;
+ top: 1rem !important;
+ right: 1rem !important;
+ z-index: 120;
+ opacity: 0.15;
+ transition: opacity 0.2s;
+}
+#detail.zen #detail-controls:hover { opacity: 1; }
+
+/* Detail close morph */
+.detail-close-frame {
+ z-index: 130;
+ pointer-events: none;
+ border: 3px solid var(--t-accent2);
+ border-radius: 10px;
+ background: transparent;
+ opacity: 0.95;
+ box-sizing: border-box;
+ will-change: left, top, width, height, opacity, border-radius;
+ transition: left 0.34s cubic-bezier(0.22, 1, 0.36, 1), top 0.34s cubic-bezier(0.22, 1, 0.36, 1), width 0.34s cubic-bezier(0.22, 1, 0.36, 1), height 0.34s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.34s ease, border-radius 0.34s ease;
+}
+.card-morph-target {
+ animation: cardMorphTarget 0.34s ease;
+}
+@keyframes cardMorphTarget {
+ 0% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
+ 45% { transform: scale(1.015); box-shadow: 0 0 0 2px var(--t-accent2); }
+ 100% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
+}
+
+/* Undo hint */
+#undo-hint { opacity: 0; transition: opacity 0.3s, transform 0.3s; pointer-events: none; border-radius: 4px; }
+
/* Overlays */
.overlay { transition: opacity 0.15s; }
.overlay:not(.open) { opacity: 0; pointer-events: none; }
@@ -142,7 +192,17 @@ body::after {
/* Buckets */
.bucket-tab.active { border-color: var(--t-accent); color: var(--t-accent); }
-.bucket-tab.drag-over { border-color: var(--t-accent); background: var(--t-raised); }
+.bucket-tab.drag-over {
+ background: var(--t-accent) !important;
+ color: var(--t-bg) !important;
+ border-color: var(--t-accent) !important;
+ transform: scale(1.05);
+ transition: all 0.15s ease;
+}
+.bucket-tab.drag-over i,
+.bucket-tab.drag-over span { color: inherit !important; }
+.bucket-drop-left { border-left: 3px solid var(--t-accent) !important; }
+.bucket-drop-right { border-right: 3px solid var(--t-accent) !important; }
/* Toolbar */
.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: 3px; transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; }
@@ -161,7 +221,7 @@ body::after {
.search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; }
/* Context menu */
-#ctx-menu .ctx-item {
+#ctx-menu .ctx-item, #bucket-ctx .ctx-item {
display: flex;
align-items: center;
gap: 8px;
@@ -176,10 +236,9 @@ body::after {
font-family: inherit;
text-align: left;
}
-#ctx-menu .ctx-item:hover { background: var(--t-raised); }
-#ctx-menu .ctx-item.danger { color: var(--t-danger); }
-#ctx-menu .ctx-item.danger:hover { background: var(--t-danger); color: var(--t-bg); }
-#ctx-menu .ctx-sep { height: 1px; background: var(--t-border); margin: 2px 0; }
+#ctx-menu .ctx-item:hover, #bucket-ctx .ctx-item:hover { background: var(--t-raised); }
+#ctx-menu .ctx-item.danger, #bucket-ctx .ctx-item.danger { color: var(--t-danger); }
+#ctx-menu .ctx-item.danger:hover, #bucket-ctx .ctx-item.danger:hover { background: var(--t-danger); color: var(--t-bg); }
/* Card links */
.card-link { color: var(--t-accent2); cursor: pointer; border-bottom: 1px dashed var(--t-accent2); transition: color 0.1s; }
diff --git a/public/index.php b/public/index.php
index b9797b3..f23ab5a 100644
--- a/public/index.php
+++ b/public/index.php
@@ -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/')) {
+
@@ -299,14 +387,14 @@ if (str_starts_with($route, '/api/')) {
}}}
+