This commit is contained in:
th committed 2026-04-09 19:14:52 +02:00
1 parent 5aa3abc860
commit 5f9db690a4
24 files changed
+1412 -562

No files matched your search

+110 -1
View File
@@ -87,9 +87,12 @@ hyprcrd/
├── config.php # App constants (auto-configured)
├── Dockerfile
├── docker-entrypoint.sh
├── dist.sh # Build distribution ZIP
├── src/
│ ├── bootstrap.php # Initialization
│ └── Database.php # SQLite schema & migrations
│ ├── Database.php # SQLite schema & migrations
│ ├── CalDavClient.php # CalDAV HTTP client + iCal parser
│ └── CalDavSync.php # Bidirectional CalDAV sync engine
├── public/ # Document root
│ ├── index.php # Entry point & API
│ ├── .htaccess # URL rewriting
@@ -104,6 +107,11 @@ hyprcrd/
│ │ ├── modules/ # Feature modules
│ │ └── vendor/ # marked.js, DOMPurify
│ └── uploads/ # User media (writable)
├── relay/ # Relay service (mobile inbox + card sharing)
│ ├── index.php # Single-file API + Mobile UI
│ ├── .htaccess # URL rewriting + data protection
│ ├── Dockerfile
│ └── dist.sh # Build relay distribution ZIP
└── data/
└── hyprcrd.db # SQLite database (auto-created)
```
@@ -123,6 +131,11 @@ hyprcrd/
- **CalDAV sync** with ownCloud/Nextcloud (bidirectional, `#termin` tagged cards)
- **Inline calculator** (`?3+5` → `8`, `34+56?` → `34+56=90`)
- **Snippets** (`?dat`, `?time`, `?now`, `?todo`, `?table`, `?code`, ...)
- **Due dates** with overdue notifications and pulsing cards
- **Relay** for mobile note capture and card sharing between instances
- **Notification banner** for upcoming events, birthdays, overdue tasks
- **Board split view** with side-by-side preview
- **Superview** compact multi-column list with hover cards
- **Keyboard shortcuts**: `Ctrl+K` search, `F1` help, `Shift+scroll` zoom, arrow keys in autocomplete
## Data & Backup
@@ -204,6 +217,99 @@ The built-in calendar shows cards on their dates:
- Appointment template includes Zeit, Ort, Teilnehmer, Notizen fields
- Navigate months with arrow buttons, **today** jumps to current date
## Vault Key (Multi-Instance Encryption)
When running multiple hyprcrd instances that share CalDAV credentials, you need a shared encryption key.
### Setup
1. Go to **Settings > Account > Vault Key**
2. Enter the same key (min 8 characters) on all instances
3. Click **save**
### How it works
- The vault key encrypts/decrypts CalDAV credentials stored in the database
- Key derivation: `SHA-256(vault_key)` → deterministic, same on all instances
- Without a vault key, encryption falls back to the login password hash (single-instance only)
- Changing the vault key automatically re-encrypts CalDAV credentials
- Changing the login password does NOT affect encryption when a vault key is set
### Important
- The vault key is stored in plaintext in each instance's database — it is NOT synced
- You must enter it manually on each connected instance
## Relay
A minimal web service for mobile note capture and card sharing between hyprcrd instances.
### Architecture
```
[Phone/Browser] → https://relay.example.com → [Relay Service]
↓ poll
[hyprcrd (local)]
[hyprcrd A] → push card → [Relay] ← poll ← [hyprcrd B]
```
### Deploy to Webspace
```bash
cd relay
./dist.sh
```
Upload the resulting `hyprcrd-relay.zip` to your webspace and extract it. The SQLite database is created automatically on first request. Open the URL and set a password + shared secret.
Requirements: PHP 8.3+ with `pdo_sqlite`, Apache with `mod_rewrite`.
### Deploy with Docker
```bash
cd relay
docker build -t hyprcrd-relay .
docker run -d -p 8081:80 -v relay-data:/var/www/html/data hyprcrd-relay
```
### Development (both together)
```bash
./dev.sh
```
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding.
From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
### Setup
1. Open the relay URL, set a **password** (for UI login) and a **shared secret** (for encryption)
2. In hyprcrd: **Settings > Relay** — enter the relay URL and the same shared secret
3. Click **save**
### How it works
- **Mobile inbox**: type a note on the relay UI, it arrives as a card with `#inbox` tag
- **Push to relay**: right-click any card → "push to relay" — other instances pick it up on next poll
- Messages are encrypted client-side (AES-256-CBC) before sending to the relay
- The relay only stores ciphertext — it cannot read your messages
- hyprcrd polls the relay every 45 seconds in the background
- Messages are kept on the relay for 24 hours, then auto-deleted
- Cards pushed from the same instance are not re-imported
- Enter to send, Shift+Enter for newline
### Relay Security
- Separate password (UI login) and shared secret (API + encryption)
- Bcrypt hashing for both credentials
- Rate-limited login (5 attempts/minute)
- AES-256-CBC client-side encryption — relay never sees plaintext
- Security headers (X-Frame-Options, HSTS, X-Content-Type-Options)
- SQLite database blocked from direct download via `.htaccess`
- Request size limited to 256KB
## Rebuilding Tailwind CSS
After changing Tailwind utility classes in HTML or JS files:
@@ -224,6 +330,9 @@ curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/ta
- Upload directory blocks PHP execution
- DOMPurify sanitizes all rendered markdown
- Security headers (X-Frame-Options, HSTS, etc.)
- AES-256-CBC encryption for CalDAV credentials and Relay messages
- Vault key system for multi-instance CalDAV encryption (independent of login password)
- Relay: separate password + shared secret, rate-limited, client-side encryption
## License
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '0.5.1');
define('APP_VERSION', '0.7.1');
// Database path — writable directory, outside webroot
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# Start hyprcrd + relay for local development
cd "$(dirname "$0")"
docker compose up -d --build
echo ""
echo " hyprcrd: http://localhost:8088"
echo " relay: http://localhost:8081"
echo ""
echo " relay from hyprcrd container: http://relay:80"
echo ""
+13
View File
@@ -8,3 +8,16 @@ services:
- ./src:/var/www/html/src
- ./public:/var/www/html/public
- ./config.php:/var/www/html/config.php
relay:
build: ./relay
ports:
- "8081:80"
volumes:
- ./relay/index.php:/var/www/html/index.php
- ./relay/.htaccess:/var/www/html/.htaccess
- ./relay/fonts:/var/www/html/fonts
- relay-data:/var/www/html/data
volumes:
relay-data:
+4
View File
@@ -220,6 +220,10 @@ body::after {
transform: rotate(45deg); margin-top: -2px;
}
.card-dragging { opacity: 0.15; transform: scale(0.95); }
.box-select {
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
pointer-events: none; z-index: 10;
}
/* Drop indicators — thick glowing bar */
.card-drop-before::before,
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::fiLine truncated
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolderLine truncated
+412 -132
View File
@@ -3,7 +3,6 @@
require_once __DIR__ . '/../src/bootstrap.php';
require_once __DIR__ . '/../src/CalDavClient.php';
require_once __DIR__ . '/../src/CalDavSync.php';
require_once __DIR__ . '/../src/WebDavNotes.php';
$db = Database::connect();
@@ -105,6 +104,49 @@ if (str_starts_with($route, '/api/auth')) {
exit;
}
// GET/POST /api/auth/vault-key
if ($route === '/api/auth/vault-key' && $method === 'GET') {
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
echo json_encode(['has_key' => !empty($vk)]); exit;
}
if ($route === '/api/auth/vault-key' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$newKey = $input['key'] ?? '';
if (strlen($newKey) < 8) { http_response_code(400); echo json_encode(['error' => 'min 8 characters']); exit; }
$oldVk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
$oldKey = $oldVk ? substr(hash('sha256', $oldVk), 0, 32) : null;
if (!$oldKey) {
// Fallback: try old password-hash-based key
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
$oldKey = substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
}
$newKeyDerived = substr(hash('sha256', $newKey), 0, 32);
// Re-encrypt CalDAV credentials
$credKeys = ['caldav_url', 'caldav_username', 'caldav_password'];
foreach ($credKeys as $ck) {
$enc = $db->query("SELECT value FROM settings WHERE key = '{$ck}'")->fetchColumn();
if ($enc) {
$raw = base64_decode($enc);
if (strlen($raw) >= 17) {
$iv = substr($raw, 0, 16);
$cipher = base64_encode(substr($raw, 16));
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $oldKey, 0, $iv);
if ($plain !== false) {
$newIv = random_bytes(16);
$newCipher = openssl_encrypt($plain, 'aes-256-cbc', $newKeyDerived, 0, $newIv);
$db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => base64_encode($newIv . base64_decode($newCipher)), 'k' => $ck]);
}
}
}
}
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('vault_key', :v)")->execute(['v' => $newKey]);
echo json_encode(['ok' => true, 'rekey' => true]); exit;
}
// POST /api/auth/password — change password (requires current password)
if ($route === '/api/auth/password' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
@@ -121,11 +163,17 @@ if (str_starts_with($route, '/api/auth')) {
echo json_encode(['error' => 'min 4 characters']);
exit;
}
// Re-encrypt CalDAV credentials with new key (atomic — rollback on failure)
$oldKey = substr(hash('sha256', $hash), 0, 32);
$newHash = password_hash($newPass, PASSWORD_BCRYPT);
$newKey = substr(hash('sha256', $newHash), 0, 32);
$hasVaultKey = !empty($db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn());
if ($hasVaultKey) {
// Vault key handles encryption — just change the password
$db->prepare("UPDATE settings SET value = :h WHERE key = 'password_hash'")->execute(['h' => $newHash]);
echo json_encode(['ok' => true]);
} else {
// Legacy: re-encrypt credentials with new password hash
$oldKey = substr(hash('sha256', $hash), 0, 32);
$newKey = substr(hash('sha256', $newHash), 0, 32);
$credKeys = ['caldav_url', 'caldav_username', 'caldav_password'];
$reEncrypted = [];
$rekeyOk = true;
@@ -135,35 +183,22 @@ if (str_starts_with($route, '/api/auth')) {
if (!$enc) continue;
$raw = base64_decode($enc);
if (strlen($raw) < 17) continue;
$iv = substr($raw, 0, 16);
$cipher = base64_encode(substr($raw, 16));
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $oldKey, 0, $iv);
$plain = openssl_decrypt(base64_encode(substr($raw, 16)), 'aes-256-cbc', $oldKey, 0, substr($raw, 0, 16));
if ($plain === false) { $rekeyOk = false; break; }
$newIv = random_bytes(16);
$newCipher = openssl_encrypt($plain, 'aes-256-cbc', $newKey, 0, $newIv);
$reEncrypted[$ck] = base64_encode($newIv . base64_decode($newCipher));
}
if (!$rekeyOk) {
http_response_code(500);
echo json_encode(['error' => 'could not re-encrypt credentials — password NOT changed. Please re-enter CalDAV credentials first.']);
exit;
}
if (!$rekeyOk) { http_response_code(500); echo json_encode(['error' => 'set a vault key first to avoid encryption issues']); exit; }
// All re-encryption succeeded — commit everything
$db->beginTransaction();
// Save old hash for re-key recovery
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('prev_password_hash', :h)")->execute(['h' => $hash]);
foreach ($reEncrypted as $ck => $val) {
$db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => $val, 'k' => $ck]);
}
foreach ($reEncrypted as $ck => $val) $db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => $val, 'k' => $ck]);
$db->prepare("UPDATE settings SET value = :h WHERE key = 'password_hash'")->execute(['h' => $newHash]);
$db->commit();
// Mark all notes for re-push (re-encrypt with new key)
$db->exec("UPDATE cards SET caldav_etag = NULL, caldav_synced_at = NULL WHERE caldav_uid LIKE 'note:%'");
echo json_encode(['ok' => true, 'rekey' => true]);
}
exit;
}
@@ -239,7 +274,7 @@ if (str_starts_with($route, '/api/')) {
}
if ($route === '/api/cards/meta' && $method === 'GET') {
echo json_encode($db->query('SELECT c.id, c.title, c.color, c.tags, c.bucket_id, c.parent_id, c.position, c.created_at, c.updated_at, c.due_date, c.caldav_uid, c.caldav_rrule, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
echo json_encode($db->query('SELECT c.id, c.title, c.color, c.tags, c.bucket_id, c.parent_id, c.position, c.created_at, c.updated_at, c.due_date, c.caldav_uid, c.caldav_rrule, c.relay_uid, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
exit;
}
@@ -595,6 +630,132 @@ if (str_starts_with($route, '/api/')) {
exit;
}
// Import Logseq vault
if ($route === '/api/import-logseq' && $method === 'POST') {
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400); echo json_encode(['error' => 'no file']); exit;
}
$zip = new ZipArchive();
if ($zip->open($_FILES['file']['tmp_name']) !== true) {
http_response_code(400); echo json_encode(['error' => 'invalid zip']); exit;
}
$uploadDir = __DIR__ . '/uploads';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
// Find the vault root (directory containing journals/ or pages/)
$prefix = '';
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (preg_match('#^(.*/)?journals/#', $name, $m)) { $prefix = $m[1] ?? ''; break; }
if (preg_match('#^(.*/)?pages/#', $name, $m)) { $prefix = $m[1] ?? ''; break; }
}
// 1. Copy assets → uploads with name mapping
$assetMap = []; // original filename → new hex filename
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (!str_starts_with($name, $prefix . 'assets/')) continue;
if (str_ends_with($name, '/')) continue;
$content = $zip->getFromIndex($i);
if ($content === false) continue;
$origName = basename($name);
$ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin';
$newName = bin2hex(random_bytes(16)) . '.' . strtolower($ext);
file_put_contents($uploadDir . '/' . $newName, $content);
$assetMap[$origName] = $newName;
}
// Helper: rewrite image references in markdown body
$rewriteImages = function($body) use ($assetMap) {
// Logseq patterns: ![desc](../assets/file.png), ![desc](/assets/file.png), ![desc](assets/file.png)
$body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?(?:assets/)?([^)]+)\)#', function($m) use ($assetMap) {
$file = basename($m[2]);
if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')';
return $m[0];
}, $body);
// Logseq block-level image embed: {{embed ((image))}} or just bare refs
// Also handle: ![image.png](../assets/image.png) with URL-encoded filenames
$body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?assets/([^)]+)\)#', function($m) use ($assetMap) {
$file = urldecode(basename($m[2]));
if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')';
return $m[0];
}, $body);
return $body;
};
// Helper: clean Logseq markdown (remove bullet prefixes, properties)
$cleanBody = function($body) {
$lines = explode("\n", $body);
$cleaned = [];
foreach ($lines as $line) {
// Skip Logseq properties (key:: value at start)
if (preg_match('/^[a-z_-]+::\s/', $line)) continue;
// Remove leading "- " bullet outline format (Logseq default)
$line = preg_replace('/^(\t*)- /', '$1', $line);
$cleaned[] = $line;
}
return implode("\n", $cleaned);
};
// Get or create "import" bucket
$importBucket = $db->query("SELECT id FROM buckets WHERE name = 'import'")->fetchColumn();
if (!$importBucket) {
$db->prepare("INSERT INTO buckets (name, position) VALUES ('import', 999)")->execute();
$importBucket = $db->lastInsertId();
}
$imported = 0;
// 2. Import journals → cards with date title
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (!str_starts_with($name, $prefix . 'journals/')) continue;
if (!str_ends_with($name, '.md')) continue;
$content = $zip->getFromIndex($i);
if ($content === false || !trim($content)) continue;
$basename = pathinfo(basename($name), PATHINFO_FILENAME);
// 2025_06_18 → 2025-06-18
$title = str_replace('_', '-', $basename);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $title)) continue;
$body = $rewriteImages($cleanBody($content));
// Extract tags
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
$db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id, created_at) VALUES (?, ?, ?, ?, -1, ?, ?)')
->execute([$title, trim($body), '#38bdf8', $tags, $importBucket, $title . ' 00:00:00']);
$imported++;
}
// 3. Import pages → cards with page name as title
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (!str_starts_with($name, $prefix . 'pages/')) continue;
if (!str_ends_with($name, '.md')) continue;
$content = $zip->getFromIndex($i);
if ($content === false || !trim($content)) continue;
$title = pathinfo(basename($name), PATHINFO_FILENAME);
// Skip Logseq internal pages
if (in_array(strtolower($title), ['contents', 'favorites'])) continue;
$body = $rewriteImages($cleanBody($content));
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
$db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id) VALUES (?, ?, ?, ?, -1, ?)')
->execute([$title, trim($body), '#8b5cf6', $tags, $importBucket]);
$imported++;
}
$zip->close();
echo json_encode(['ok' => true, 'imported' => $imported, 'assets' => count($assetMap)]);
exit;
}
// CalDAV endpoints
$caldav = new CalDavSync($db);
@@ -616,131 +777,223 @@ if (str_starts_with($route, '/api/')) {
echo json_encode($db->query('SELECT * FROM caldav_sync_log ORDER BY id DESC LIMIT 50')->fetchAll()); exit;
}
// WebDAV Notes sync
if ($route === '/api/notes/sync' && $method === 'POST') {
$notes = new WebDavNotes($db);
echo json_encode($notes->sync()); exit;
// ── Relay ──
if ($route === '/api/relay/settings' && $method === 'GET') {
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
$hasKey = !empty($db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn());
echo json_encode(['url' => $url, 'has_key' => $hasKey]); exit;
}
// Re-key notes: decrypt with old key, re-encrypt with current key
if ($route === '/api/notes/rekey' && $method === 'POST') {
if ($route === '/api/relay/settings' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$oldPass = $input['old_password'] ?? '';
if (!$oldPass) { http_response_code(400); echo json_encode(['error' => 'old password required']); exit; }
if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]);
if (!empty($input['key'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
echo json_encode(['ok' => true]); exit;
}
$currentHash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
$currentKey = substr(hash('sha256', $currentHash), 0, 32);
$prevHash = $db->query("SELECT value FROM settings WHERE key = 'prev_password_hash'")->fetchColumn();
if ($route === '/api/relay/poll' && $method === 'POST') {
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => true, 'pulled' => 0]); exit; }
// Build candidate old keys: try prev hash first, then verify password against it
$candidateKeys = [];
if ($prevHash) {
$candidateKeys[] = substr(hash('sha256', $prevHash), 0, 32);
// Maybe password was changed multiple times — prev_hash might not match oldPass
if (password_verify($oldPass, $prevHash)) {
// Confirmed: prev hash matches old password
array_unshift($candidateKeys, substr(hash('sha256', $prevHash), 0, 32));
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) { echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit; }
$messages = json_decode($response, true);
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
$pulled = 0;
$encKey = substr(hash('sha256', $relayKey), 0, 32);
// Load IDs we pushed ourselves
$ownIds = array_column($db->query('SELECT relay_msg_id FROM relay_pushed')->fetchAll(), 'relay_msg_id');
foreach ($messages as $msg) {
if ($msg['fetched'] ?? false) continue;
if (in_array($msg['id'] ?? 0, $ownIds)) {
// Clean up tracking row since relay will delete it after 24h anyway
$db->prepare('DELETE FROM relay_pushed WHERE relay_msg_id = ?')->execute([$msg['id']]);
continue;
}
// Decrypt
$title = $msg['title'] ?? '';
$body = $msg['body'] ?? '';
if (str_starts_with($body, 'HYPRCRD-RELAY-V1')) {
$enc = trim(explode("\n", $body, 2)[1] ?? '');
$raw = base64_decode($enc);
if (strlen($raw) >= 17) {
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($plain !== false) $body = $plain;
}
}
if (str_starts_with($title, 'HYPRCRD-RELAY-V1')) {
$enc = trim(explode("\n", $title, 2)[1] ?? '');
$raw = base64_decode($enc);
if (strlen($raw) >= 17) {
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($plain !== false) $title = $plain;
}
}
// Also try current key (notes might already be on current key)
$candidateKeys[] = $currentKey;
// Get WebDAV client
$caldavSync = new CalDavSync($db);
$settings = $caldavSync->getSettings();
if (empty($settings['url'])) { echo json_encode(['ok' => false, 'error' => 'no CalDAV credentials']); exit; }
if (!$title) $title = substr(strip_tags($body), 0, 60) ?: 'Relay message';
$body = trim($body);
$relayUid = $msg['relay_uid'] ?? null;
$url = $settings['url'];
$user = $settings['username'];
$passEnc = $db->query("SELECT value FROM settings WHERE key = 'caldav_password'")->fetchColumn();
$passRaw = base64_decode($passEnc);
$passIv = substr($passRaw, 0, 16);
$passCipher = base64_encode(substr($passRaw, 16));
$pass = openssl_decrypt($passCipher, 'aes-256-cbc', $currentKey, 0, $passIv) ?: '';
// Check for existing card with this relay_uid
$existingCard = null;
if ($relayUid) {
$stmt = $db->prepare('SELECT id FROM cards WHERE relay_uid = ?');
$stmt->execute([$relayUid]);
$existingCard = $stmt->fetch();
}
if (!$pass) { echo json_encode(['ok' => false, 'error' => 'cannot decrypt CalDAV password']); exit; }
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
if (preg_match('#(https?://[^/]+)/remote\.php/dav/calendars/([^/]+)#', $url, $m)) {
$notesUrl = $m[1] . '/remote.php/dav/files/' . $m[2] . '/Notes/';
} else { echo json_encode(['ok' => false, 'error' => 'cannot derive notes URL']); exit; }
if ($existingCard) {
// Update existing card
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
$db->prepare('UPDATE cards SET title = ?, body = ?, color = ?, tags = ?, updated_at = datetime("now") WHERE id = ?')
->execute([$title, $body, $msg['color'] ?? '#38bdf8', $tags, $existingCard['id']]);
} else {
// New card
if (!str_contains($body, '#inbox')) $body .= "\n\n#inbox";
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
$db->prepare('INSERT INTO cards (title, body, color, tags, position, relay_uid) VALUES (?, ?, ?, ?, -1, ?)')
->execute([$title, $body, $msg['color'] ?? '#38bdf8', $tags, $relayUid]);
}
$pulled++;
$client = new CalDavClient($notesUrl, $user, $pass);
$notes = new WebDavNotes($db);
$remoteNotes = $notes->listRemoteNotes();
if (empty($remoteNotes)) { echo json_encode(['ok' => false, 'error' => 'no remote notes']); exit; }
// Download attachments
if ($relayUid) {
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid));
curl_setopt_array($ach, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
$attResponse = curl_exec($ach);
$attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE);
curl_close($ach);
// Find working key by testing first encrypted note
$parsed = parse_url($notesUrl);
$base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
$workingKey = null;
foreach ($remoteNotes as $testNote) {
$testRes = $client->request('GET', $base . $testNote['href']);
$testBody = $testRes['body'] ?? '';
if (!str_starts_with($testBody, 'HYPRCRD-ENCRYPTED-V1')) continue;
$encData = trim(explode("\n", $testBody, 2)[1] ?? '');
$raw = base64_decode($encData);
if (strlen($raw) < 17) continue;
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
foreach ($candidateKeys as $tryKey) {
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $tryKey, OPENSSL_RAW_DATA, $iv);
if ($plain !== false && (str_contains($plain, '#') || str_contains($plain, "\n"))) {
$workingKey = $tryKey;
break 2;
if ($attStatus === 200) {
$attachments = json_decode($attResponse, true);
$uploadDir = __DIR__ . '/uploads';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
foreach ($attachments ?? [] as $att) {
$fn = basename($att['filename']);
if (!preg_match('/^[a-f0-9]+\.\w+$/', $fn)) continue;
$dest = $uploadDir . '/' . $fn;
if (file_exists($dest)) continue;
$raw = base64_decode($att['data']);
if (strlen($raw) >= 17) {
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($decrypted !== false) file_put_contents($dest, $decrypted);
}
}
}
}
if (!$workingKey) {
echo json_encode(['ok' => false, 'error' => 'could not decrypt — old key not found']);
exit;
}
if ($workingKey === $currentKey) {
echo json_encode(['ok' => true, 'rekeyed' => 0, 'message' => 'notes already on current key']);
exit;
echo json_encode(['ok' => true, 'pulled' => $pulled]); exit;
}
// Re-encrypt all notes
$count = 0;
foreach ($remoteNotes as $note) {
$res = $client->request('GET', $base . $note['href']);
if ($res['status'] !== 200) continue;
$body = $res['body'];
if (!str_starts_with($body, 'HYPRCRD-ENCRYPTED-V1')) continue;
// Push a card to relay
if ($route === '/api/relay/push' && $method === 'POST') {
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit; }
$encData = trim(explode("\n", $body, 2)[1] ?? '');
$raw = base64_decode($encData);
if (strlen($raw) < 17) continue;
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $workingKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($plain === false) continue;
$input = json_decode(file_get_contents('php://input'), true);
$cardId = $input['card_id'] ?? null;
if (!$cardId) { http_response_code(400); echo json_encode(['error' => 'card_id required']); exit; }
$newIv = random_bytes(16);
$newCipher = openssl_encrypt($plain, 'aes-256-cbc', $currentKey, OPENSSL_RAW_DATA, $newIv);
$client->request('PUT', $base . $note['href'], "HYPRCRD-ENCRYPTED-V1\n" . base64_encode($newIv . $newCipher), ['Content-Type: text/markdown; charset=utf-8']);
$count++;
$card = $db->prepare('SELECT * FROM cards WHERE id = ?');
$card->execute([$cardId]);
$card = $card->fetch();
if (!$card) { http_response_code(404); echo json_encode(['error' => 'card not found']); exit; }
// Generate relay_uid if not set
$relayUid = $card['relay_uid'] ?? null;
if (!$relayUid) {
$relayUid = bin2hex(random_bytes(16));
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $cardId]);
}
// Clear and re-pull
$db->exec("DELETE FROM cards WHERE caldav_uid LIKE 'note:%'");
$db->exec("DELETE FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'");
$db->exec("DELETE FROM settings WHERE key = 'prev_password_hash'");
$encKey = substr(hash('sha256', $relayKey), 0, 32);
$encryptField = function($text) use ($encKey) {
$iv = random_bytes(16);
$cipher = openssl_encrypt($text, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
};
echo json_encode(['ok' => true, 'rekeyed' => $count]);
exit;
$payload = json_encode([
'title' => $encryptField($card['title'] ?? ''),
'body' => $encryptField($card['body'] ?? ''),
'color' => $card['color'] ?? '#38bdf8',
'tags' => $card['tags'] ?? '',
'relay_uid' => $relayUid,
]);
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 200 || $status === 201) {
$resData = json_decode($response, true);
if (!empty($resData['id'])) {
$db->prepare("INSERT OR IGNORE INTO relay_pushed (relay_msg_id) VALUES (?)")->execute([$resData['id']]);
}
// Force re-sync: clear all notes state and re-pull
if ($route === '/api/notes/reset' && $method === 'POST') {
$deleted = $db->exec("DELETE FROM cards WHERE caldav_uid LIKE 'note:%'");
$db->exec("DELETE FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'");
$notes = new WebDavNotes($db);
$result = $notes->sync();
$result['reset'] = $deleted;
echo json_encode($result); exit;
// Upload attachments (images referenced in body)
$cardBody = $card['body'] ?? '';
preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $cardBody, $attMatches);
$uploadDir = __DIR__ . '/uploads';
foreach (array_unique($attMatches[1] ?? []) as $filename) {
$filePath = $uploadDir . '/' . $filename;
if (!file_exists($filePath)) continue;
$raw = file_get_contents($filePath);
$iv = random_bytes(16);
$cipher = openssl_encrypt($raw, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
$attPayload = json_encode([
'relay_uid' => $relayUid,
'filename' => $filename,
'data' => base64_encode($iv . $cipher),
]);
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments');
curl_setopt_array($ach, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $attPayload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ach);
curl_close($ach);
}
echo json_encode(['ok' => true]); exit;
}
echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit;
}
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
@@ -835,7 +1088,7 @@ if (str_starts_with($route, '/api/')) {
</header>
<div id="upcoming-banner" class="hidden shrink-0"></div>
<div id="board-wrapper" class="flex flex-1 min-h-0 overflow-hidden">
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pt-5 pb-6 flex-1 overflow-y-auto content-start"></div>
<div id="board" class="relative grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pt-5 pb-6 flex-1 overflow-y-auto content-start"></div>
<div id="board-preview" class="hidden border-l border-t-border bg-t-surface flex flex-col" style="width:40%;min-width:300px;max-width:500px;">
<div id="board-preview-header" class="flex items-center gap-2 px-4 py-2.5 border-b border-t-border shrink-0 relative">
<div class="card-bar w-8 rounded-sm" id="board-preview-color"></div>
@@ -859,17 +1112,21 @@ if (str_starts_with($route, '/api/')) {
<footer class="shrink-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">
<input type="file" id="import-logseq-file" accept=".zip,application/zip" class="hidden">
<button id="logout-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-danger hover:text-t-danger transition-colors">
<i class="ph ph-sign-out text-sm"></i><span class="font-pixel text-[10px]">logout</span>
</button>
<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="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>
<button id="export-md-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-vault text-sm"></i><span class="font-pixel text-[10px]">vault</span>
<button id="import-logseq-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-graph text-sm"></i><span class="font-pixel text-[10px]">logseq import</span>
</button>
<button id="logout-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-danger hover:text-t-danger transition-colors">
<i class="ph ph-sign-out text-sm"></i><span class="font-pixel text-[10px]">logout</span>
<button id="export-md-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-vault text-sm"></i><span class="font-pixel text-[10px]">export vault</span>
</button>
<button id="settings-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-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">settings</span>
@@ -1080,7 +1337,8 @@ if (str_starts_with($route, '/api/')) {
<button class="settings-tab active" data-tab="branding"><i class="ph ph-terminal-window text-sm"></i><span class="font-pixel text-[10px]">hyprcrd</span></button>
<button class="settings-tab" data-tab="account"><i class="ph ph-user text-sm"></i><span class="font-pixel text-[10px]">account</span></button>
<button class="settings-tab" data-tab="theme"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">themes</span></button>
<button class="settings-tab" data-tab="sync"><i class="ph ph-cloud-arrow-up text-sm"></i><span class="font-pixel text-[10px]">sync</span></button>
<button class="settings-tab" data-tab="caldav"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">calendar</span></button>
<button class="settings-tab" data-tab="relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">relay</span></button>
<button class="settings-tab" data-tab="behavior"><i class="ph ph-sliders text-sm"></i><span class="font-pixel text-[10px]">behavior</span></button>
<button class="settings-tab" data-tab="notifications"><i class="ph ph-bell text-sm"></i><span class="font-pixel text-[10px]">notifications</span></button>
</div>
@@ -1112,8 +1370,8 @@ if (str_starts_with($route, '/api/')) {
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3">color theme</div>
<div id="settings-theme-list" class="grid grid-cols-2 gap-2"></div>
</div>
<div id="settings-panel-sync" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2">CalDAV / WebDAV</div>
<div id="settings-panel-caldav" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-calendar-dots text-xs"></i> CalDAV sync</div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">server url</label><input id="caldav-url" type="text" name="caldav_url_x" placeholder="https://cloud.example.com/remote.php/dav/calendars/user/" autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
<div class="flex gap-3">
<div class="flex-1"><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">username</label><input id="caldav-user" type="text" name="caldav_user_x" autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
@@ -1130,14 +1388,22 @@ if (str_starts_with($route, '/api/')) {
<div class="flex flex-wrap gap-2 pt-2 border-t border-t-border">
<button id="caldav-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="caldav-sync-now" class="flex items-center gap-1.5 px-3 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-arrows-clockwise text-sm"></i><span class="font-pixel text-[10px]">sync now</span></button>
<button id="notes-reset" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-arrow-counter-clockwise text-sm"></i><span class="font-pixel text-[10px]">reset notes</span></button>
<button id="notes-rekey" class="flex items-center gap-1.5 px-3 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-key text-sm"></i><span class="font-pixel text-[10px]">re-key notes</span></button>
</div>
<div class="flex items-center gap-2">
<span id="caldav-sync-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
<div id="caldav-log" class="text-[10px] text-t-dim font-mono max-h-32 overflow-y-auto hidden border-t border-t-border pt-2"></div>
</div>
<div id="settings-panel-relay" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-broadcast text-xs"></i> relay</div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">relay url</label><input id="relay-url" type="text" placeholder="https://relay.example.com" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">shared secret</label><input id="relay-key" type="text" placeholder="shared secret" autocomplete="off" style="-webkit-text-security:disc;" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
<div class="flex gap-2">
<button id="relay-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="relay-test" class="flex items-center gap-1.5 px-3 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-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
<span id="relay-status" class="font-pixel text-[9px] text-t-dim self-center"></span>
</div>
</div>
<div id="settings-panel-behavior" class="hidden space-y-5">
<div>
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-lightning text-xs"></i> current view</div>
@@ -1247,7 +1513,17 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
<div id="settings-panel-account" class="hidden space-y-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3">change password</div>
<div>
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-key text-xs"></i> vault key</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-2">Shared encryption key for Notes and CalDAV credentials. Must be identical on all connected hyprcrd instances.</p>
<div class="flex gap-2">
<input id="settings-vault-key" type="text" placeholder="enter vault key" autocomplete="off" style="-webkit-text-security:disc;" class="flex-1 bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
<button id="settings-vault-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
</div>
<span id="settings-vault-status" class="font-pixel text-[9px] text-t-dim mt-1 block"></span>
</div>
<div class="border-t border-t-border pt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><i class="ph ph-lock text-xs"></i> change password</div>
<div>
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">current password</label>
<input id="settings-pw-current" type="password" autocomplete="off" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
@@ -1269,6 +1545,7 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
</div>
</div>
<!-- Dialog -->
<div class="overlay fixed inset-0 z-[90] flex justify-center items-center px-4" style="background:var(--t-overlay)" id="dialog">
@@ -1447,7 +1724,7 @@ if (str_starts_with($route, '/api/')) {
<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 id="gallery-grid" class="flex-1 overflow-y-auto p-4 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 content-start select-none"></div>
</div>
</div>
@@ -1466,6 +1743,9 @@ if (str_starts_with($route, '/api/')) {
<button id="select-tag" 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-hash text-sm"></i><span class="font-pixel text-[10px]">tag</span>
</button>
<button id="select-unbucket" 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-folder-minus text-sm"></i><span class="font-pixel text-[10px]">unsort</span>
</button>
<button id="select-export" 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>
+18 -1
View File
@@ -16,7 +16,7 @@ import { undo, canUndo } from './modules/undo.js';
import { openGallery, closeGallery } from './modules/gallery.js';
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings } from './modules/caldav.js';
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js';
// ── Global API (for inline onclick handlers and cross-module calls) ──
@@ -29,6 +29,7 @@ window._renderBoard = renderBoard;
window._openCalendar = openCalendar;
window._triggerSync = triggerSync;
window._loadCaldavSettings = loadCaldavSettings;
window._loadRelaySettings = loadRelaySettings;
window._renderTagBar = renderTagBar;
window._showContextMenu = showContextMenu;
window._ungroupCard = ungroupCard;
@@ -139,6 +140,22 @@ importFile?.addEventListener('change', async e => {
e.target.value = '';
});
const importLogseq = document.getElementById('import-logseq-file');
document.getElementById('import-logseq-btn')?.addEventListener('click', () => importLogseq?.click());
importLogseq?.addEventListener('change', async e => {
const file = e.target.files?.[0];
if (!file) return;
const form = new FormData(); form.append('file', file);
try {
const res = await fetch('api/import-logseq', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
const data = JSON.parse(await res.text());
if (data.error) throw new Error(data.error);
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
await showDialog({ title: 'logseq import', message: `${data.imported} cards, ${data.assets} images imported`, okLabel: 'ok' });
} catch (err) { console.error('Logseq import failed:', err); }
e.target.value = '';
});
// ── Auth ──
document.getElementById('logout-btn')?.addEventListener('click', async () => {
+187 -10
View File
@@ -73,6 +73,31 @@ function clearSelection() {
export function getSelectedIds() { return [...selectedIds]; }
export { clearSelection };
// Delete/Backspace deletes selected cards
document.addEventListener('keydown', async e => {
if (!selectedIds.size) return;
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
e.preventDefault();
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
const { showDialog } = await import('./dialog.js');
const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' });
if (!ok) return;
}
for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'DELETE' });
clearSelection();
await window._reloadAll();
});
// Clear selection on click outside board (header, footer, etc)
document.addEventListener('click', e => {
if (!selectedIds.size) return;
if (board.contains(e.target)) return;
if (selectBar?.contains(e.target)) return;
if (document.querySelector('.bucket-picker')?.contains(e.target)) return;
clearSelection();
});
$('select-export')?.addEventListener('click', () => {
if (!selectedIds.size) return;
window.location.href = 'api/export?ids=' + [...selectedIds].join(',');
@@ -89,23 +114,66 @@ $('select-group')?.addEventListener('click', async () => {
await window._reloadAll();
});
$('select-bucket')?.addEventListener('click', async () => {
$('select-bucket')?.addEventListener('click', () => {
if (!selectedIds.size) return;
const { showDialog } = await import('./dialog.js');
const bucketOpts = state.buckets.map(b => b.name);
const name = await showDialog({ title: 'move to bucket', input: true, placeholder: 'bucket name', okLabel: 'move' });
// Remove existing picker
document.querySelector('.bucket-picker')?.remove();
const btn = $('select-bucket');
const rect = btn.getBoundingClientRect();
const picker = document.createElement('div');
picker.className = 'bucket-picker';
picker.style.cssText = `position:fixed;left:${rect.left}px;bottom:${window.innerHeight - rect.top + 4}px;z-index:200;background:var(--t-surface);border:1px solid var(--t-border);border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:180px;max-height:260px;display:flex;flex-direction:column;`;
const list = document.createElement('div');
list.style.cssText = 'overflow-y:auto;flex:1;';
state.buckets.forEach(b => {
const item = document.createElement('button');
item.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:6px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;';
item.innerHTML = `<span style="width:8px;height:8px;border-radius:2px;background:${b.color || '#64748b'};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;">${esc(b.name)}</span>`;
item.addEventListener('mouseenter', () => { item.style.color = 'var(--t-bright)'; });
item.addEventListener('mouseleave', () => { item.style.color = 'var(--t-muted)'; });
item.addEventListener('click', async () => {
picker.remove();
for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: b.id } });
clearSelection();
await window._reloadAll();
});
list.appendChild(item);
});
picker.appendChild(list);
const inputWrap = document.createElement('div');
inputWrap.style.cssText = 'border-top:1px solid var(--t-border);padding:6px;';
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'new bucket...';
input.style.cssText = 'width:100%;background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:4px 8px;font-size:11px;font-family:var(--t-font-pixel);border-radius:3px;';
input.addEventListener('keydown', async e => {
if (e.key !== 'Enter') return;
const name = input.value.trim();
if (!name) return;
let bucket = state.buckets.find(b => b.name.toLowerCase() === name.trim().toLowerCase());
picker.remove();
let bucket = state.buckets.find(b => b.name.toLowerCase() === name.toLowerCase());
if (!bucket) {
const created = await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
const created = await api('api/buckets', { method: 'POST', body: { name } });
if (created?.id) bucket = created;
}
if (!bucket) return;
for (const id of selectedIds) {
await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucket.id } });
}
for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucket.id } });
clearSelection();
await window._reloadAll();
});
inputWrap.appendChild(input);
picker.appendChild(inputWrap);
document.body.appendChild(picker);
input.focus();
const closePicker = e => { if (!picker.contains(e.target) && e.target !== btn) { picker.remove(); cleanup(); } };
const closeOnEsc = e => { if (e.key === 'Escape') { e.stopPropagation(); picker.remove(); cleanup(); } };
const cleanup = () => { document.removeEventListener('mousedown', closePicker); document.removeEventListener('keydown', closeOnEsc, true); };
setTimeout(() => { document.addEventListener('mousedown', closePicker); document.addEventListener('keydown', closeOnEsc, true); }, 0);
});
$('select-color')?.addEventListener('click', async () => {
@@ -137,6 +205,15 @@ $('select-tag')?.addEventListener('click', async () => {
await window._reloadAll();
});
$('select-unbucket')?.addEventListener('click', async () => {
if (!selectedIds.size) return;
for (const id of selectedIds) {
await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: null } });
}
clearSelection();
await window._reloadAll();
});
$('select-delete')?.addEventListener('click', async () => {
if (!selectedIds.size) return;
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
@@ -568,7 +645,11 @@ export function renderBoard() {
if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`;
// Refresh split preview if open
if (boardSplitOpen && boardPreviewCardId) showBoardPreview(boardPreviewCardId);
if (boardSplitOpen && boardPreviewCardId) {
const stillExists = state.cards.some(c => c.id == boardPreviewCardId);
if (stillExists) showBoardPreview(boardPreviewCardId);
else { boardPreviewCardId = null; resetBoardPreview(); }
}
// Notification banner
renderNotifs();
@@ -790,6 +871,18 @@ function bindCardEvents() {
window._renderTagBar?.();
renderBoard();
});
seg.addEventListener('contextmenu', async e => {
e.preventDefault();
e.stopPropagation();
const tag = seg.dataset.tag;
const cardEl = seg.closest('[data-id]');
if (!cardEl) return;
const card = state.cards.find(c => c.id == cardEl.dataset.id);
if (!card) return;
const newBody = (card.body || '').replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
await window._reloadAll?.();
});
});
board.querySelectorAll('[data-id]').forEach(el => {
@@ -997,3 +1090,87 @@ async function handleCardDrop(e, el, targetId) {
requestAnimationFrame(() => { card.style.transition = 'transform 0.25s ease'; card.style.transform = ''; });
});
}
// ── Box select ──
let boxEl = null;
let boxStart = null;
let boxActive = false;
board.addEventListener('mousedown', e => {
// Only start on board background (not on cards or interactive elements)
if (e.button !== 0 || e.target !== board) return;
if (e.shiftKey) return;
const additive = e.ctrlKey || e.metaKey;
const priorSelection = additive ? new Set(selectedIds) : null;
boxStart = { x: e.clientX, y: e.clientY, scrollTop: board.scrollTop };
boxActive = false;
const onMove = ev => {
const dx = ev.clientX - boxStart.x;
const dy = ev.clientY - boxStart.y;
if (!boxActive && Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
if (!boxActive) {
boxActive = true;
if (!additive) clearSelection();
boxEl = document.createElement('div');
boxEl.className = 'box-select';
board.appendChild(boxEl);
}
const boardRect = board.getBoundingClientRect();
const scrollDelta = board.scrollTop - boxStart.scrollTop;
const x1 = boxStart.x - boardRect.left;
const y1 = boxStart.y - boardRect.top + board.scrollTop;
const x2 = ev.clientX - boardRect.left;
const y2 = ev.clientY - boardRect.top + board.scrollTop;
const left = Math.min(x1, x2);
const top = Math.min(y1, y2);
const width = Math.abs(x2 - x1);
const height = Math.abs(y2 - y1);
boxEl.style.left = left + 'px';
boxEl.style.top = top + 'px';
boxEl.style.width = width + 'px';
boxEl.style.height = height + 'px';
// Check intersections
const selRect = { left, top, right: left + width, bottom: top + height };
board.querySelectorAll('[data-id]').forEach(card => {
const cr = card.getBoundingClientRect();
const cardRect = {
left: cr.left - boardRect.left,
top: cr.top - boardRect.top + board.scrollTop,
right: cr.right - boardRect.left,
bottom: cr.bottom - boardRect.top + board.scrollTop,
};
const intersects = !(cardRect.right < selRect.left || cardRect.left > selRect.right || cardRect.bottom < selRect.top || cardRect.top > selRect.bottom);
const id = card.dataset.id;
const wasPrior = priorSelection?.has(id);
if (additive) {
// Toggle: prior selected + hit by box → deselect; not prior + hit → select
const shouldBeSelected = intersects ? !wasPrior : !!wasPrior;
if (shouldBeSelected && !selectedIds.has(id)) toggleSelect(id);
else if (!shouldBeSelected && selectedIds.has(id)) toggleSelect(id);
} else {
if (intersects && !selectedIds.has(id)) toggleSelect(id);
else if (!intersects && selectedIds.has(id)) toggleSelect(id);
}
});
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (boxEl) { boxEl.remove(); boxEl = null; }
if (!boxActive && selectedIds.size) clearSelection();
boxStart = null;
boxActive = false;
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
+10 -3
View File
@@ -22,6 +22,7 @@ async function showBucketCtx(x, y, bucket) {
const idx = state.buckets.findIndex(b => b.id == bucket.id);
const canLeft = idx > 0;
const canRight = idx < state.buckets.length - 1;
const hasCards = state.cards.some(c => c.bucket_id == bucket.id);
ctxMenu.innerHTML = `<div class="py-1">
<button class="ctx-item" data-baction="rename"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">rename</span></button>
@@ -30,8 +31,11 @@ async function showBucketCtx(x, y, bucket) {
<button class="ctx-item ${!canLeft ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-left"><i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">move left</span></button>
<button class="ctx-item ${!canRight ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-right"><i class="ph ph-arrow-right text-sm"></i><span class="font-pixel text-[10px]">move right</span></button>
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
<button class="ctx-item" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete (keep cards)</span></button>
<button class="ctx-item danger" data-baction="delete-all"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete + cards</span></button>
${hasCards
? `<button class="ctx-item" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete (keep cards)</span></button>
<button class="ctx-item danger" data-baction="delete-all"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete + cards</span></button>`
: `<button class="ctx-item danger" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span></button>`
}
</div>
<div id="bctx-colors" class="hidden px-3 py-2 border-t border-t-border">
<div class="flex flex-wrap gap-1.5">
@@ -70,8 +74,11 @@ async function showBucketCtx(x, y, bucket) {
}
else if (a === 'delete-keep') {
hideBucketCtx();
const ok = await showDialog({ title: 'delete_bucket', message: `"${bucket.name}" loeschen? Karten bleiben erhalten.`, danger: true, okLabel: 'delete' });
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `"${bucket.name}" loeschen? ${cards.length} Karte(n) bleiben erhalten.` : `"${bucket.name}" loeschen?`, danger: true, okLabel: 'delete' });
if (!ok) return;
}
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
await loadBuckets(); await window._reloadAll();
+41 -41
View File
@@ -32,8 +32,8 @@ const logEl = $('caldav-log');
export function openCaldavSettings() {
// Open settings overlay on sync tab
settingsOverlay?.classList.add('open');
settingsOverlay?.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === 'sync'));
settingsOverlay?.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.toggle('hidden', p.id !== 'settings-panel-sync'));
settingsOverlay?.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === 'caldav'));
settingsOverlay?.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.toggle('hidden', p.id !== 'settings-panel-caldav'));
loadSettings();
}
@@ -150,19 +150,6 @@ $('caldav-save')?.addEventListener('click', async () => {
});
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
$('notes-reset')?.addEventListener('click', async () => {
if (syncStatus) { syncStatus.textContent = 'resetting notes...'; syncStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/notes/reset', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.json()).catch(() => null);
if (res?.ok) {
const s = res.stats || {};
if (syncStatus) { syncStatus.textContent = `reset: ${res.reset || 0} cleared, ${s.pulled || 0} re-pulled`; syncStatus.style.color = 'var(--t-accent)'; }
await window._reloadAll?.();
} else {
if (syncStatus) { syncStatus.textContent = res?.error || 'reset failed'; syncStatus.style.color = 'var(--t-danger)'; }
}
});
export async function triggerSync() {
const btn = $('cal-sync');
@@ -175,18 +162,18 @@ export async function triggerSync() {
if (btn) btn.style.opacity = '';
// Also sync WebDAV Notes
const notesRes = await fetch('api/notes/sync', {
// Also poll relay
const relayRes = await fetch('api/relay/poll', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.json()).catch(() => null);
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (res.ok) {
const s = res.stats || {};
const ns = notesRes?.stats || {};
const total = { pushed: (s.pushed || 0) + (ns.pushed || 0), pulled: (s.pulled || 0) + (ns.pulled || 0), deleted: (s.deleted || 0) + (ns.deleted || 0) };
const allLog = [...(res.log || []), ...(notesRes?.log || [])];
const rp = relayRes?.pulled || 0;
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
const allLog = res.log || [];
const hasErrors = allLog.some(l => l.status === 'error');
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${ns.pulled ? ' notes:' + ns.pulled : ''}`;
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
await window._reloadAll?.();
showLog(allLog);
@@ -212,25 +199,38 @@ function showConnectionLog(lines) {
).join('');
}
$('notes-rekey')?.addEventListener('click', async () => {
const { showDialog } = await import('./dialog.js');
const oldPass = await showDialog({ title: 're-key notes', input: 'password', placeholder: 'old password', okLabel: 're-key' });
if (!oldPass) return;
if (syncStatus) { syncStatus.textContent = 're-keying notes...'; syncStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/notes/rekey', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ old_password: oldPass }),
}).then(r => r.json()).catch(() => null);
// ── Relay settings ──
$('relay-save')?.addEventListener('click', async () => {
const relayStatus = $('relay-status');
const body = { url: $('relay-url')?.value || '' };
const key = $('relay-key')?.value;
if (key) body.key = key;
await api('api/relay/settings', { method: 'POST', body });
if (relayStatus) { relayStatus.textContent = 'saved'; relayStatus.style.color = 'var(--t-accent)'; }
});
$('relay-test')?.addEventListener('click', async () => {
const relayStatus = $('relay-status');
if (relayStatus) { relayStatus.textContent = 'testing...'; relayStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/relay/poll', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null);
if (res?.ok) {
if (syncStatus) { syncStatus.textContent = `re-keyed ${res.rekeyed} notes — syncing...`; syncStatus.style.color = 'var(--t-accent)'; }
// Re-pull with correct key
const pullRes = await fetch('api/notes/reset', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null);
await window._reloadAll?.();
if (syncStatus) syncStatus.textContent = `re-keyed ${res.rekeyed} notes, ${pullRes?.stats?.pulled || 0} re-pulled`;
if (relayStatus) { relayStatus.textContent = `ok — ${res.pulled} pulled`; relayStatus.style.color = 'var(--t-accent)'; }
if (res.pulled) await window._reloadAll?.();
} else {
if (syncStatus) { syncStatus.textContent = res?.error || 're-key failed'; syncStatus.style.color = 'var(--t-danger)'; }
if (relayStatus) { relayStatus.textContent = res?.error || 'failed'; relayStatus.style.color = 'var(--t-danger)'; }
}
});
export async function loadRelaySettings() {
const s = await api('api/relay/settings');
if (!s) return;
const urlInput = $('relay-url');
const keyInput = $('relay-key');
if (urlInput) urlInput.value = s.url || '';
if (keyInput) keyInput.placeholder = s.has_key ? '(saved)' : 'shared secret';
}
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
$('cal-sync')?.addEventListener('click', () => triggerSync());
@@ -265,13 +265,13 @@ async function backgroundSync() {
bgSyncRunning = true;
try {
const headers = { 'X-CSRF-Token': getCsrf() };
const [res, notesRes] = await Promise.all([
const [res, relayRes] = await Promise.all([
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/notes/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
]);
const s = res?.stats || {};
const ns = notesRes?.stats || {};
const total = { pushed: (s.pushed || 0) + (ns.pushed || 0), pulled: (s.pulled || 0) + (ns.pulled || 0), deleted: (s.deleted || 0) + (ns.deleted || 0) };
const rp = relayRes?.pulled || 0;
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
await window._reloadAll?.();
showSyncNotification(total);
+12
View File
@@ -38,6 +38,7 @@ export function showContextMenu(x, y, card) {
<button class="ctx-item" data-action="due-date"><i class="ph ph-clock-countdown text-sm ${card.due_date ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${card.due_date ? 'due: ' + card.due_date : 'set due date'}</span></button>
${card.due_date ? `<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm text-t-accent"></i><span class="font-pixel text-[10px]">mark done</span></button>` : ''}
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">push to relay</span></button>
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</span></button>
<div class="ctx-sep"></div>
<button class="ctx-item" data-action="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
@@ -205,6 +206,17 @@ export function showContextMenu(x, y, card) {
await window._reloadAll();
}
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
else if (a === 'push-relay') {
hideContextMenu();
const res = await api(`api/relay/push`, { method: 'POST', body: { card_id: card.id } });
const msg = res?.ok ? 'pushed to relay' : (res?.error || 'push failed');
const color = res?.ok ? 'var(--t-accent)' : 'var(--t-danger)';
const n = document.createElement('div');
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);`;
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${msg}</div>`;
document.body.appendChild(n);
setTimeout(() => { n.style.transition='opacity .3s'; n.style.opacity='0'; setTimeout(() => n.remove(), 350); }, 3000);
}
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
else if (a === 'color') toggle(colors);
else if (a === 'bucket') toggle(buckets);
+33 -2
View File
@@ -73,8 +73,14 @@ export async function openGallery() {
if (orphaned.length) {
html += `<div class="col-span-full font-pixel text-[10px] text-t-danger flex items-center gap-1.5 pt-2"><i class="ph ph-warning text-xs"></i>orphaned (not used by any card)</div>`;
html += orphaned.map(f => `
<div class="gallery-item group relative cursor-pointer" data-orphan="${f.name}">
${mediaTile(f.url, 'border-t-danger/30')}
<div class="gallery-item group cursor-pointer" data-orphan="${f.name}">
<div class="relative aspect-square bg-t-bg border border-t-danger/30 rounded overflow-visible">
<img src="${f.url}" class="w-full h-full object-cover opacity-60 rounded" loading="lazy">
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" title="new card"><i class="ph ph-plus text-sm"></i></button>
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" title="delete"><i class="ph ph-trash text-sm"></i></button>
</div>
</div>
<div class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div>
</div>`).join('');
}
@@ -114,6 +120,31 @@ export async function openGallery() {
if (f && !isAudio(f.url)) openOrphanLightbox(f);
});
});
// Orphan: inline new card button
grid.querySelectorAll('[data-new-orphan]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const name = btn.dataset.newOrphan;
const label = name.replace(/\.[^.]+$/, '');
closeGallery();
window._openModal({ id: null, title: label, body: `![${label}](uploads/${name})`, color: '#38bdf8', bucket_id: null });
});
});
// Orphan: inline delete button
grid.querySelectorAll('[data-del-orphan]').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const name = btn.dataset.delOrphan;
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
const ok = await showDialog({ title: 'delete_image', message: `"${name}" loeschen?`, danger: true, okLabel: 'delete' });
if (!ok) return;
}
await fetch(`api/uploads/${name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
openGallery();
});
});
}
el.classList.add('open');
+30 -1
View File
@@ -32,7 +32,8 @@ overlay?.querySelectorAll('.settings-tab').forEach(tab => {
const panel = tab.dataset.tab;
overlay.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden'));
$(`settings-panel-${panel}`)?.classList.remove('hidden');
if (panel === 'sync') window._loadCaldavSettings?.();
if (panel === 'caldav') { window._loadCaldavSettings?.(); }
if (panel === 'relay') { window._loadRelaySettings?.(); }
if (panel === 'notifications') renderNotifSettings();
if (panel === 'behavior') initBehaviorSettings();
});
@@ -46,9 +47,37 @@ function updateStats() {
const st = $('stats-tags'); if (st) st.textContent = tags.size;
}
// Vault key
$('settings-vault-save')?.addEventListener('click', async () => {
const key = $('settings-vault-key')?.value;
const status = $('settings-vault-status');
if (!key || key.length < 8) { if (status) { status.textContent = 'min 8 characters'; status.style.color = 'var(--t-danger)'; } return; }
const res = await fetch('api/auth/vault-key', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ key }),
}).then(r => r.json()).catch(() => ({ error: 'failed' }));
if (res.ok) {
if (status) { status.textContent = res.rekey ? 'saved — notes will re-encrypt on next sync' : 'saved'; status.style.color = 'var(--t-accent)'; }
$('settings-vault-key').value = '';
if (res.rekey) window._triggerSync?.();
} else {
if (status) { status.textContent = res.error || 'failed'; status.style.color = 'var(--t-danger)'; }
}
});
async function loadVaultStatus() {
const res = await api('api/auth/vault-key');
if (!res) return;
const status = $('settings-vault-status');
const input = $('settings-vault-key');
if (input) input.placeholder = res.has_key ? '(set — enter new to change)' : 'enter vault key';
if (status && res.has_key) { status.textContent = 'vault key is set'; status.style.color = 'var(--t-dim)'; }
}
$('settings-btn')?.addEventListener('click', () => {
renderThemes();
updateStats();
loadVaultStatus();
overlay?.classList.add('open');
});
$('settings-close')?.addEventListener('click', () => overlay?.classList.remove('open'));
+8
View File
@@ -0,0 +1,8 @@
RewriteEngine On
# Block direct access to data directory
RewriteRule ^data/ - [F,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
+11
View File
@@ -0,0 +1,11 @@
FROM php:8.3-apache
RUN a2enmod rewrite
RUN apt-get update && apt-get install -y libsqlite3-dev && rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install pdo_sqlite
RUN sed -ri -e 's/AllowOverride None/AllowOverride All/g' /etc/apache2/apache2.conf
RUN mkdir -p /var/www/html/data && chmod 777 /var/www/html/data
EXPOSE 80
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Build hyprcrd-relay distribution ZIP
# Output: a ZIP you can extract into any PHP webspace
set -e
cd "$(dirname "$0")"
DIST="hyprcrd-relay.zip"
rm -f "$DIST"
zip -r "$DIST" index.php .htaccess fonts/
echo "Done: ${DIST} ($(du -h "$DIST" | cut -f1))"
echo ""
echo "Deploy: extract into your webspace document root."
echo "The data/ directory and SQLite DB are created automatically on first request."
Binary file not shown.
Binary file not shown.
Binary file not shown.
+476
View File
@@ -0,0 +1,476 @@
<?php
// ── hyprcrd Relay — Mobile Inbox ──
// Single-file service: API + Mobile UI + DB
error_reporting(0);
$dbPath = __DIR__ . '/data/relay.db';
$dbDir = dirname($dbPath);
if (!is_dir($dbDir)) mkdir($dbDir, 0755, true);
$db = new PDO('sqlite:' . $dbPath, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec('PRAGMA journal_mode = WAL');
$db->exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)');
$db->exec('CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT DEFAULT "",
body TEXT NOT NULL,
color TEXT DEFAULT "#38bdf8",
tags TEXT DEFAULT "",
relay_uid TEXT DEFAULT NULL,
fetched INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)');
// Migration: add relay_uid if missing
$msgCols = array_column($db->query('PRAGMA table_info(messages)')->fetchAll(), 'name');
if (!in_array('relay_uid', $msgCols)) {
$db->exec('ALTER TABLE messages ADD COLUMN relay_uid TEXT DEFAULT NULL');
}
$db->exec('CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
relay_uid TEXT NOT NULL,
filename TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)');
// Cleanup: delete older than 24h
$db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')");
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
// Rate-limit table
$db->exec('CREATE TABLE IF NOT EXISTS rate_limits (
ip TEXT NOT NULL,
endpoint TEXT NOT NULL,
attempted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
$db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-5 minutes')");
function isRateLimited(PDO $db, string $endpoint, int $max = 10): bool {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$count = $db->prepare("SELECT COUNT(*) FROM rate_limits WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-1 minutes')");
$count->execute(['ip' => $ip, 'ep' => $endpoint]);
if ((int)$count->fetchColumn() >= $max) return true;
$db->prepare("INSERT INTO rate_limits (ip, endpoint) VALUES (:ip, :ep)")->execute(['ip' => $ip, 'ep' => $endpoint]);
return false;
}
$route = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$base = dirname($_SERVER['SCRIPT_NAME']);
if ($base !== '/') $route = substr($route, strlen($base));
$route = '/' . ltrim($route ?: '/', '/');
$method = $_SERVER['REQUEST_METHOD'];
$getKey = fn() => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: null;
$getPassword = fn() => $db->query("SELECT value FROM settings WHERE key = 'relay_password'")->fetchColumn() ?: null;
$isSetup = !$getKey() || !$getPassword();
// ── Security headers ──
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
if (!empty($_SERVER['HTTPS']) || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
// ── API ──
if (str_starts_with($route, '/api/')) {
header('Content-Type: application/json');
// CORS: allow same-origin + configured hyprcrd instances
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
}
header('Access-Control-Allow-Headers: Content-Type, X-Relay-Key');
header('Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS');
if ($method === 'OPTIONS') { exit; }
// Reject oversized requests (max 256KB)
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
$sizeLimit = ($route === '/api/attachments') ? 15728640 : 262144; // 15MB for attachments, 256KB otherwise
if ($contentLength > $sizeLimit) { http_response_code(413); echo json_encode(['error' => 'payload too large']); exit; }
// Setup
if ($route === '/api/setup' && $method === 'POST') {
if (isRateLimited($db, 'setup', 5)) { http_response_code(429); echo json_encode(['error' => 'too many attempts']); exit; }
if (!$isSetup && $getKey() && $getPassword()) { http_response_code(403); echo json_encode(['error' => 'already configured']); exit; }
$input = json_decode(file_get_contents('php://input'), true);
$password = $input['password'] ?? '';
$key = $input['key'] ?? '';
if (strlen($password) < 4) { http_response_code(400); echo json_encode(['error' => 'password must be at least 4 characters']); exit; }
if (strlen($key) < 4) { http_response_code(400); echo json_encode(['error' => 'shared secret must be at least 4 characters']); exit; }
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_password', :v)")->execute(['v' => password_hash($password, PASSWORD_BCRYPT)]);
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => password_hash($key, PASSWORD_BCRYPT)]);
echo json_encode(['ok' => true]); exit;
}
// Verify UI password
if ($route === '/api/login' && $method === 'POST') {
if (isRateLimited($db, 'login', 5)) { http_response_code(429); echo json_encode(['error' => 'too many attempts']); exit; }
$input = json_decode(file_get_contents('php://input'), true);
$password = $input['password'] ?? '';
$storedHash = $getPassword();
if ($storedHash && password_verify($password, $storedHash)) {
echo json_encode(['ok' => true]); exit;
}
http_response_code(401); echo json_encode(['error' => 'wrong password']); exit;
}
// Verify shared secret (for client-side key check)
if ($route === '/api/verify' && $method === 'POST') {
if (isRateLimited($db, 'verify', 5)) { http_response_code(429); echo json_encode(['error' => 'too many attempts']); exit; }
$input = json_decode(file_get_contents('php://input'), true);
$key = $input['key'] ?? '';
$storedHash = $getKey();
if ($storedHash && password_verify($key, $storedHash)) {
echo json_encode(['ok' => true]); exit;
}
http_response_code(401); echo json_encode(['error' => 'wrong key']); exit;
}
// Status
if ($route === '/api/status' && $method === 'GET') {
echo json_encode(['setup' => $isSetup, 'messages' => (int)$db->query('SELECT COUNT(*) FROM messages WHERE fetched = 0')->fetchColumn()]); exit;
}
// Auth check for all other endpoints (shared secret)
$authKey = $_SERVER['HTTP_X_RELAY_KEY'] ?? '';
$storedHash = $getKey();
if (!$storedHash || !password_verify($authKey, $storedHash)) {
http_response_code(401); echo json_encode(['error' => 'unauthorized']); exit;
}
// Post message
if ($route === '/api/messages' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$body = $input['body'] ?? '';
if (!$body) { http_response_code(400); echo json_encode(['error' => 'body required']); exit; }
$stmt = $db->prepare('INSERT INTO messages (title, body, color, tags, relay_uid) VALUES (:t, :b, :c, :tags, :uid)');
$stmt->execute([
't' => $input['title'] ?? '',
'b' => $body,
'c' => $input['color'] ?? '#38bdf8',
'tags' => $input['tags'] ?? '',
'uid' => $input['relay_uid'] ?? null,
]);
echo json_encode(['ok' => true, 'id' => $db->lastInsertId()]); exit;
}
// Get messages (for hyprcrd polling)
if ($route === '/api/messages' && $method === 'GET') {
$since = $_GET['since'] ?? null;
if ($since) {
$stmt = $db->prepare("SELECT * FROM messages WHERE created_at > :s ORDER BY created_at ASC");
$stmt->execute(['s' => $since]);
} else {
$stmt = $db->query("SELECT * FROM messages ORDER BY created_at ASC");
}
$msgs = $stmt->fetchAll();
$db->exec("UPDATE messages SET fetched = 1 WHERE fetched = 0");
echo json_encode($msgs); exit;
}
// Delete message
if (preg_match('#^/api/messages/(\d+)$#', $route, $m) && $method === 'DELETE') {
$db->prepare('DELETE FROM messages WHERE id = :id')->execute(['id' => $m[1]]);
echo json_encode(['ok' => true]); exit;
}
// Upload attachment
if ($route === '/api/attachments' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$relayUid = $input['relay_uid'] ?? '';
$filename = $input['filename'] ?? '';
$data = $input['data'] ?? '';
if (!$relayUid || !$filename || !$data) { http_response_code(400); echo json_encode(['error' => 'relay_uid, filename, and data required']); exit; }
// Replace existing attachment with same uid+filename
$db->prepare('DELETE FROM attachments WHERE relay_uid = :uid AND filename = :fn')->execute(['uid' => $relayUid, 'fn' => $filename]);
$db->prepare('INSERT INTO attachments (relay_uid, filename, data) VALUES (:uid, :fn, :data)')->execute(['uid' => $relayUid, 'fn' => $filename, 'data' => $data]);
echo json_encode(['ok' => true, 'id' => $db->lastInsertId()]); exit;
}
// Get attachments for a relay_uid
if ($route === '/api/attachments' && $method === 'GET') {
$relayUid = $_GET['relay_uid'] ?? '';
if (!$relayUid) { http_response_code(400); echo json_encode(['error' => 'relay_uid required']); exit; }
$stmt = $db->prepare('SELECT id, relay_uid, filename, data, created_at FROM attachments WHERE relay_uid = :uid');
$stmt->execute(['uid' => $relayUid]);
echo json_encode($stmt->fetchAll()); exit;
}
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
}
// ── Mobile UI ──
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1e293b">
<title>hyprcrd relay</title>
<style>
@font-face { font-family:'Silkscreen'; font-weight:400; src:url('fonts/silkscreen-400.ttf') format('truetype'); }
@font-face { font-family:'Silkscreen'; font-weight:700; src:url('fonts/silkscreen-700.ttf') format('truetype'); }
@font-face { font-family:'JetBrains Mono'; font-weight:400; src:url('fonts/jetbrains-mono-400.ttf') format('truetype'); }
:root { --bg:#1e293b; --surface:#273349; --border:#384a64; --dim:#64748b; --muted:#94a3b8; --text:#cbd5e1; --bright:#f1f5f9; --accent:#38bdf8; --danger:#fb7185; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'JetBrains Mono',monospace; background:var(--bg); color:var(--text); height:100dvh; display:flex; flex-direction:column; overflow:hidden; }
.pixel { font-family:'Silkscreen',cursive; }
.hidden { display:none !important; }
header { padding:12px 16px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; flex-shrink:0; }
header h1 { font-family:'Silkscreen',cursive; font-size:14px; color:var(--accent); }
header .dot { width:8px; height:8px; border-radius:50%; background:var(--accent); animation:pulse 2s ease-in-out infinite; }
header .logout { background:none; border:none; color:var(--dim); cursor:pointer; font-size:14px; padding:4px; }
header .logout:hover { color:var(--danger); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
.compose { flex:1; display:flex; flex-direction:column; padding:12px; gap:8px; min-height:0; }
.compose input { background:var(--surface); border:1px solid var(--border); color:var(--bright); padding:8px 12px; font-size:13px; font-family:'Silkscreen',cursive; border-radius:4px; }
.compose input:focus { border-color:var(--accent); outline:none; }
.compose textarea { flex:1; background:var(--surface); border:1px solid var(--border); color:var(--text); padding:12px; font-size:14px; font-family:inherit; border-radius:4px; resize:none; line-height:1.6; }
.compose textarea:focus { border-color:var(--accent); outline:none; }
.actions { padding:8px 12px; border-top:1px solid var(--border); display:flex; gap:8px; flex-shrink:0; }
.btn { padding:10px 20px; border:none; border-radius:4px; font-family:'Silkscreen',cursive; font-size:11px; cursor:pointer; transition:opacity .15s; }
.btn:active { opacity:.7; }
.btn-send { background:var(--accent); color:var(--bg); flex:1; }
.btn-clear { background:transparent; border:1px solid var(--border); color:var(--muted); }
.toast { position:fixed; top:12px; left:50%; transform:translateX(-50%); background:var(--accent); color:var(--bg); padding:8px 20px; border-radius:4px; font-family:'Silkscreen',cursive; font-size:11px; opacity:0; transition:opacity .3s; z-index:10; }
.toast.show { opacity:1; }
.pending { padding:0 12px 12px; overflow-y:auto; flex-shrink:0; max-height:30dvh; }
.pending-item { display:flex; align-items:center; gap:8px; padding:8px; border-bottom:1px solid var(--border); font-size:12px; }
.pending-item .time { font-family:'Silkscreen',cursive; font-size:9px; color:var(--dim); flex-shrink:0; }
.pending-item .text { flex:1; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.pending-item .del { background:none; border:none; color:var(--dim); cursor:pointer; font-size:16px; padding:4px; }
.pending-item .fetched { color:var(--accent); font-size:10px; }
.auth-form { display:flex; flex-direction:column; align-items:center; padding:48px 24px; gap:12px; }
.auth-form input { width:100%; max-width:280px; background:var(--surface); border:1px solid var(--border); color:var(--bright); padding:8px 12px; font-size:13px; font-family:inherit; border-radius:4px; }
.auth-form input:focus { border-color:var(--accent); outline:none; }
.auth-form .btn { width:100%; max-width:280px; }
.auth-form .label { font-size:10px; color:var(--dim); width:100%; max-width:280px; margin-top:4px; }
</style>
</head>
<body>
<header>
<span class="dot"></span>
<h1>hyprcrd relay</h1>
<span style="flex:1"></span>
<span id="header-status" class="pixel" style="font-size:9px;color:var(--dim);"></span>
<button id="logout-btn" class="logout hidden" onclick="doLogout()" title="logout">✕</button>
</header>
<!-- Setup: first time -->
<div id="view-setup" class="auth-form hidden">
<p class="pixel" style="font-size:11px;color:var(--muted);">Set up relay</p>
<span class="pixel label">password (for login)</span>
<input type="password" id="setup-pw" placeholder="min 4 chars">
<span class="pixel label" style="margin-top:8px;">shared secret (for encryption)</span>
<input type="password" id="setup-key" placeholder="min 4 chars">
<button class="btn btn-send" onclick="doSetup()" style="margin-top:4px;">activate</button>
<div id="setup-err" class="pixel" style="font-size:10px;color:var(--danger);"></div>
</div>
<!-- Login -->
<div id="view-login" class="auth-form hidden">
<p class="pixel" style="font-size:11px;color:var(--muted);">Login</p>
<input type="password" id="login-pw" placeholder="password">
<button class="btn btn-send" onclick="doLogin()">unlock</button>
<div id="login-err" class="pixel" style="font-size:10px;color:var(--danger);"></div>
</div>
<!-- Shared secret prompt (after login, if missing from localStorage) -->
<div id="view-secret" class="auth-form hidden">
<p class="pixel" style="font-size:11px;color:var(--muted);">Enter shared secret for encryption</p>
<input type="password" id="secret-key" placeholder="shared secret">
<button class="btn btn-send" onclick="doSetSecret()">confirm</button>
<div id="secret-err" class="pixel" style="font-size:10px;color:var(--danger);"></div>
</div>
<!-- Compose -->
<div id="view-compose" class="compose hidden">
<input type="text" id="msg-title" placeholder="title (optional)" autocomplete="off">
<textarea id="msg-body" placeholder="write something..." autofocus></textarea>
</div>
<div id="view-pending" class="pending hidden"></div>
<div id="view-actions" class="actions hidden">
<button class="btn btn-clear" onclick="document.getElementById('msg-body').value='';document.getElementById('msg-title').value='';document.getElementById('msg-body').focus();">clear</button>
<button class="btn btn-send" onclick="sendMsg()">send</button>
</div>
<div class="toast" id="toast"></div>
<script>
const PW_STORE = 'hyprcrd-relay-pw';
const KEY_STORE = 'hyprcrd-relay-key';
const isSetup = <?= json_encode($isSetup) ?>;
function getKey() { return localStorage.getItem(KEY_STORE) || ''; }
function getPw() { return localStorage.getItem(PW_STORE) || ''; }
function deriveEncKey(secret) {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))
.then(buf => Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('').substring(0, 32));
}
async function encrypt(text) {
const key = getKey();
if (!key) return text;
const encKey = await deriveEncKey(key);
const iv = crypto.getRandomValues(new Uint8Array(16));
const keyMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(encKey), 'AES-CBC', false, ['encrypt']);
const cipher = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, keyMaterial, new TextEncoder().encode(text));
const combined = new Uint8Array(iv.length + cipher.byteLength);
combined.set(iv); combined.set(new Uint8Array(cipher), iv.length);
return 'HYPRCRD-RELAY-V1\n' + btoa(String.fromCharCode(...combined));
}
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
function showView(name) {
['setup','login','secret','compose'].forEach(v => {
document.getElementById('view-' + v)?.classList.add('hidden');
});
document.getElementById('view-pending')?.classList.add('hidden');
document.getElementById('view-actions')?.classList.add('hidden');
document.getElementById('logout-btn')?.classList.add('hidden');
document.getElementById('view-' + name)?.classList.remove('hidden');
if (name === 'compose') {
document.getElementById('view-pending')?.classList.remove('hidden');
document.getElementById('view-actions')?.classList.remove('hidden');
document.getElementById('logout-btn')?.classList.remove('hidden');
document.getElementById('msg-body')?.focus();
}
if (name === 'login' || name === 'secret') {
document.getElementById('logout-btn')?.classList.remove('hidden');
}
}
async function doSetup() {
const pw = document.getElementById('setup-pw').value;
const key = document.getElementById('setup-key').value;
const err = document.getElementById('setup-err');
if (pw.length < 4) { err.textContent = 'password: min 4 chars'; return; }
if (key.length < 4) { err.textContent = 'secret: min 4 chars'; return; }
const res = await fetch('api/setup', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ password: pw, key }) });
const data = await res.json();
if (data.ok) {
localStorage.setItem(PW_STORE, '1');
localStorage.setItem(KEY_STORE, key);
location.reload();
} else {
err.textContent = data.error || 'failed';
}
}
async function doLogin() {
const pw = document.getElementById('login-pw').value;
const err = document.getElementById('login-err');
if (!pw) return;
const res = await fetch('api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ password: pw }) });
if (res.ok) {
localStorage.setItem(PW_STORE, '1');
if (!getKey()) { showView('secret'); return; }
showView('compose');
loadPending();
} else {
err.textContent = 'wrong password';
}
}
async function doSetSecret() {
const key = document.getElementById('secret-key').value;
const err = document.getElementById('secret-err');
if (!key) return;
const res = await fetch('api/verify', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ key }) });
if (res.ok) {
localStorage.setItem(KEY_STORE, key);
showView('compose');
loadPending();
} else {
err.textContent = 'wrong secret';
}
}
function doLogout() {
localStorage.removeItem(PW_STORE);
showView('login');
document.getElementById('login-pw').value = '';
document.getElementById('login-err').textContent = '';
document.getElementById('header-status').textContent = '';
}
async function loadPending() {
try {
const res = await fetch('api/status');
const data = await res.json();
document.getElementById('header-status').textContent = data.messages + ' pending';
} catch {}
}
async function sendMsg() {
const title = document.getElementById('msg-title')?.value || '';
const body = document.getElementById('msg-body')?.value || '';
if (!body.trim()) return;
const key = getKey();
if (!key) { toast('no encryption key'); return; }
const encTitle = await encrypt(title);
const encBody = await encrypt(body);
const res = await fetch('api/messages', {
method:'POST',
headers:{ 'Content-Type':'application/json', 'X-Relay-Key': key },
body: JSON.stringify({ title: encTitle, body: encBody }),
});
const data = await res.json();
if (data.ok) {
toast('sent');
document.getElementById('msg-body').value = '';
document.getElementById('msg-title').value = '';
document.getElementById('msg-body').focus();
loadPending();
} else {
if (data.error === 'unauthorized') { doLogout(); toast('session expired'); return; }
toast(data.error || 'failed');
}
}
async function delMsg(id, btn) {
const key = getKey();
await fetch('api/messages/' + id, { method:'DELETE', headers:{'X-Relay-Key':key} });
btn.closest('.pending-item')?.remove();
loadPending();
}
document.getElementById('msg-body')?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); }
});
document.getElementById('login-pw')?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); doLogin(); }
});
document.getElementById('secret-key')?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); doSetSecret(); }
});
// Init
(async () => {
if (isSetup) { showView('setup'); return; }
const hasPw = getPw();
if (!hasPw) { showView('login'); return; }
if (getKey()) { showView('compose'); loadPending(); return; }
showView('secret');
})();
</script>
</body>
</html>
+3
View File
@@ -18,6 +18,9 @@ class CalDavSync
private function getEncKey(): string
{
$vaultKey = $this->db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
if ($vaultKey) return substr(hash('sha256', $vaultKey), 0, 32);
// Fallback: old method (password hash) for migration
$hash = $this->db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
}
+13
View File
@@ -120,6 +120,19 @@ class Database
)
');
// Relay UID for bidirectional sync
if (!in_array('relay_uid', $colNames)) {
self::$pdo->exec('ALTER TABLE cards ADD COLUMN relay_uid TEXT DEFAULT NULL');
}
// Relay pushed message IDs (skip on own poll)
self::$pdo->exec('
CREATE TABLE IF NOT EXISTS relay_pushed (
relay_msg_id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
');
// Bucket color migration
$bcols = self::$pdo->query('PRAGMA table_info(buckets)')->fetchAll();
$bcolNames = array_column($bcols, 'name');
-369
View File
@@ -1,369 +0,0 @@
<?php
require_once __DIR__ . '/CalDavClient.php';
class WebDavNotes
{
private PDO $db;
private ?CalDavClient $client = null;
private array $log = [];
private array $stats = ['pushed' => 0, 'pulled' => 0, 'deleted' => 0];
public function __construct(PDO $db)
{
$this->db = $db;
}
private function getEncKey(): string
{
$hash = $this->db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
}
private function decrypt(string $encrypted): string
{
$key = $this->getEncKey();
$raw = base64_decode($encrypted);
$iv = substr($raw, 0, 16);
$cipher = base64_encode(substr($raw, 16));
return openssl_decrypt($cipher, 'aes-256-cbc', $key, 0, $iv) ?: '';
}
private function encryptContent(string $plain): string
{
$key = $this->getEncKey();
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return "HYPRCRD-ENCRYPTED-V1\n" . base64_encode($iv . $cipher);
}
private function decryptContent(string $data): ?string
{
$lines = explode("\n", $data, 2);
if ($lines[0] !== 'HYPRCRD-ENCRYPTED-V1' || empty($lines[1])) return null;
$key = $this->getEncKey();
$raw = base64_decode(trim($lines[1]));
if (strlen($raw) < 17) return null;
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return $plain !== false ? $plain : null;
}
private function isEncrypted(string $data): bool
{
return str_starts_with($data, 'HYPRCRD-ENCRYPTED-V1');
}
private function getClient(): CalDavClient
{
if (!$this->client) {
// Derive WebDAV files URL from CalDAV URL
$caldavUrl = $this->db->query("SELECT value FROM settings WHERE key = 'caldav_url'")->fetchColumn();
$user = $this->db->query("SELECT value FROM settings WHERE key = 'caldav_username'")->fetchColumn();
$pass = $this->db->query("SELECT value FROM settings WHERE key = 'caldav_password'")->fetchColumn();
if (!$caldavUrl || !$pass) throw new \Exception('No CalDAV credentials configured');
$url = $this->decrypt($caldavUrl);
// Convert /calendars/user/... to /files/user/Notes/
if (preg_match('#(https?://[^/]+)/remote\.php/dav/calendars/([^/]+)#', $url, $m)) {
$notesUrl = $m[1] . '/remote.php/dav/files/' . $m[2] . '/Notes/';
} else {
// Fallback: replace calendars path
$notesUrl = preg_replace('#/calendars/[^/]+/.*$#', '/files/' . urlencode($this->decrypt($user)) . '/Notes/', $url);
}
$this->client = new CalDavClient($notesUrl, $this->decrypt($user), $this->decrypt($pass));
}
return $this->client;
}
private function addLog(string $dir, ?int $cardId, string $action, string $status, string $detail = ''): void
{
$this->log[] = compact('dir', 'cardId', 'action', 'status', 'detail');
}
private function safeFilename(string $title): string
{
$name = preg_replace('/[<>:"\/\\\\|?*]/', '_', $title);
return trim($name) ?: 'untitled';
}
public function listRemoteNotes(): array
{
$client = $this->getClient();
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getlastmodified/><d:getetag/><d:getcontentlength/></d:prop></d:propfind>';
$res = $client->request('PROPFIND', $client->getBaseUrl(), $body, ['Depth: 1']);
if ($res['status'] === 404) {
// Notes folder doesn't exist, create it
$client->request('MKCOL', $client->getBaseUrl());
return [];
}
if ($res['status'] !== 207) return [];
$notes = [];
try {
$xml = new SimpleXMLElement($res['body']);
$xml->registerXPathNamespace('d', 'DAV:');
$baseHref = rtrim(parse_url($client->getBaseUrl(), PHP_URL_PATH), '/');
foreach ($xml->xpath('//d:response') as $resp) {
$resp->registerXPathNamespace('d', 'DAV:');
$href = (string)($resp->xpath('.//d:href')[0] ?? '');
if (rtrim($href, '/') === $baseHref) continue;
if (!preg_match('/\.md$/i', $href)) continue;
$etag = (string)($resp->xpath('.//d:getetag')[0] ?? '');
$lastmod = (string)($resp->xpath('.//d:getlastmodified')[0] ?? '');
$filename = urldecode(basename($href));
$notes[] = ['href' => $href, 'filename' => $filename, 'etag' => $etag, 'lastmod' => $lastmod];
}
} catch (\Exception $e) {
$this->addLog('sync', null, 'list', 'error', $e->getMessage());
}
return $notes;
}
public function sync(): array
{
try {
$client = $this->getClient();
} catch (\Exception $e) {
return ['ok' => false, 'error' => $e->getMessage(), 'log' => $this->log, 'stats' => $this->stats];
}
$baseUrl = $client->getBaseUrl();
$this->addLog('sync', null, 'init', 'ok', "notes: {$baseUrl}");
// Fetch remote notes
$remoteNotes = $this->listRemoteNotes();
$this->addLog('sync', null, 'fetch', 'ok', count($remoteNotes) . ' remote notes');
$remoteMap = []; // filename -> note data
foreach ($remoteNotes as $n) {
$remoteMap[$n['filename']] = $n;
}
// Fetch local #notes cards (including previously synced)
$localCards = $this->db->query("SELECT * FROM cards WHERE tags = 'notes' OR tags LIKE 'notes,%' OR tags LIKE '%,notes,%' OR tags LIKE '%,notes' OR caldav_uid LIKE 'note:%'")->fetchAll();
$this->addLog('sync', null, 'fetch', 'ok', count($localCards) . ' local #notes cards');
// Tombstones for deleted notes
$tombstones = [];
foreach ($this->db->query("SELECT * FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'")->fetchAll() as $t) {
$tombstones[str_replace('note:', '', $t['caldav_uid'])] = $t;
}
$localByFile = []; // filename -> card
$localNew = [];
foreach ($localCards as $c) {
if ($c['caldav_href']) {
$localByFile[basename($c['caldav_href'])] = $c;
} elseif ($c['caldav_uid'] && str_starts_with($c['caldav_uid'], 'note:')) {
$storedFile = str_replace('note:', '', $c['caldav_uid']);
$localByFile[$storedFile] = $c;
} else {
$localNew[] = $c;
}
}
// A: Existing synced cards
foreach ($localByFile as $filename => $card) {
if (isset($remoteMap[$filename])) {
$remote = $remoteMap[$filename];
$lt = strtotime($card['updated_at'] ?? '2000-01-01');
$st = strtotime($card['caldav_synced_at'] ?? '2000-01-01');
$localChanged = !$card['caldav_synced_at'] || ($lt - $st) > 2;
$remoteChanged = !$card['caldav_etag'] || $card['caldav_etag'] !== $remote['etag'];
if ($remoteChanged && !$localChanged) {
// Pull
$this->pullNote($client, $remote, $card);
} elseif ($localChanged && !$remoteChanged) {
// Push
$this->pushNote($client, $card, $filename);
} elseif ($localChanged && $remoteChanged) {
// Conflict: last modified wins
$localTime = strtotime($card['updated_at']);
$remoteTime = strtotime($remote['lastmod'] ?? '2000-01-01');
if ($localTime >= $remoteTime) {
$this->pushNote($client, $card, $filename);
} else {
$this->pullNote($client, $remote, $card);
}
}
unset($remoteMap[$filename]);
} else {
// Remote deleted — always respect server deletion for notes
$this->db->prepare('DELETE FROM cards WHERE id = ?')->execute([$card['id']]);
$this->addLog('pull', $card['id'], 'delete', 'ok', "remote deleted: {$filename}");
$this->stats['deleted']++;
}
}
// B: New local cards → push (opaque encrypted filename)
foreach ($localNew as $card) {
$filename = $this->encFilename($card['id']);
$this->pushNote($client, $card, $filename);
}
// C: New remote notes → pull (skip tombstoned)
foreach ($remoteMap as $filename => $remote) {
if (isset($tombstones[$filename])) {
// Locally deleted → delete from server
$parsed = parse_url($client->getBaseUrl());
$base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
$client->request('DELETE', $base . $remote['href']);
$this->db->prepare("DELETE FROM caldav_tombstones WHERE caldav_uid = ?")->execute(['note:' . $filename]);
$this->addLog('push', null, 'delete', 'ok', "deleted remote: {$filename}");
$this->stats['deleted']++;
continue;
}
$this->pullNote($client, $remote, null);
}
// Clean up remaining tombstones (already gone from server)
foreach ($tombstones as $filename => $t) {
if (!isset($remoteMap[$filename])) {
$this->db->prepare("DELETE FROM caldav_tombstones WHERE caldav_uid = ?")->execute(['note:' . $filename]);
}
}
return ['ok' => true, 'log' => $this->log, 'stats' => $this->stats];
}
private function encFilename(int $cardId): string
{
// Deterministic opaque filename from card ID + encryption key
return 'enc-' . substr(hash('sha256', $this->getEncKey() . ':' . $cardId), 0, 12) . '.md';
}
private function pushNote(CalDavClient $client, array $card, string $filename): void
{
// Include metadata for cross-client sync
$meta = '';
if (!empty($card['bucket_name'])) {
$meta = "<!--bucket:{$card['bucket_name']}-->\n";
} elseif (!empty($card['bucket_id'])) {
$bname = $this->db->prepare('SELECT name FROM buckets WHERE id = ?');
$bname->execute([$card['bucket_id']]);
$bn = $bname->fetchColumn();
if ($bn) $meta = "<!--bucket:{$bn}-->\n";
}
if (!empty($card['color']) && $card['color'] !== '#38bdf8') {
$meta .= "<!--color:{$card['color']}-->\n";
}
$plain = "{$meta}# {$card['title']}\n\n{$card['body']}";
$content = $this->encryptContent($plain);
// Migrate old plaintext filename to encrypted opaque filename
$oldHref = $card['caldav_href'] ? basename($card['caldav_href']) : null;
if ($oldHref && !str_starts_with($oldHref, 'enc-')) {
// Delete old plaintext file
$oldUrl = $client->getBaseUrl() . rawurlencode($oldHref);
$client->request('DELETE', $oldUrl);
$filename = $this->encFilename($card['id']);
} else {
$filename = $oldHref ?: $this->encFilename($card['id']);
}
$url = $client->getBaseUrl() . rawurlencode($filename);
$res = $client->request('PUT', $url, $content, ['Content-Type: text/markdown; charset=utf-8']);
if ($res['status'] >= 200 && $res['status'] < 300) {
$etag = $res['etag'];
// If no ETag in PUT response, fetch it
if (!$etag) {
$prop = $client->request('PROPFIND', $url, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/></d:prop></d:propfind>', ['Depth: 0']);
if ($prop['status'] === 207) {
try {
$xml = new \SimpleXMLElement($prop['body']);
$xml->registerXPathNamespace('d', 'DAV:');
$found = $xml->xpath('//d:getetag');
if ($found) $etag = (string)$found[0];
} catch (\Exception $e) {}
}
}
$this->db->prepare('UPDATE cards SET caldav_uid=?, caldav_etag=?, caldav_href=?, caldav_synced_at=datetime("now") WHERE id=?')
->execute(['note:' . $filename, $etag, $filename, $card['id']]);
$this->addLog('push', $card['id'], 'push', 'ok', $filename);
$this->stats['pushed']++;
} else {
$this->addLog('push', $card['id'], 'push', 'error', "HTTP {$res['status']} {$filename}");
}
}
private function pullNote(CalDavClient $client, array $remote, ?array $card): void
{
$parsed = parse_url($client->getBaseUrl());
$base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
$url = $base . $remote['href'];
$res = $client->request('GET', $url);
if ($res['status'] !== 200) {
$this->addLog('pull', $card['id'] ?? null, 'pull', 'error', "HTTP {$res['status']} {$remote['filename']}");
return;
}
$raw = $res['body'];
// Decrypt if encrypted, fall back to plain text
if ($this->isEncrypted($raw)) {
$content = $this->decryptContent($raw);
if ($content === null) {
// Decryption failed — might be from another instance with different password
// Try treating as plain text (skip the header line)
$this->addLog('pull', $card['id'] ?? null, 'pull', 'ok', "decrypt failed, importing as plain: {$remote['filename']}");
$content = $raw;
}
} else {
$content = $raw;
}
// Extract metadata comments
$bucketName = null;
$noteColor = null;
if (preg_match('/<!--bucket:(.+?)-->/', $content, $bm)) { $bucketName = $bm[1]; $content = str_replace($bm[0] . "\n", '', $content); }
if (preg_match('/<!--color:(.+?)-->/', $content, $cm)) { $noteColor = $cm[1]; $content = str_replace($cm[0] . "\n", '', $content); }
// Resolve or create bucket
$bucketId = $card['bucket_id'] ?? null;
if ($bucketName) {
$stmt = $this->db->prepare('SELECT id FROM buckets WHERE name = ?');
$stmt->execute([$bucketName]);
$existingBucket = $stmt->fetchColumn();
if ($existingBucket) {
$bucketId = $existingBucket;
} else {
$this->db->prepare('INSERT INTO buckets (name, position) VALUES (?, (SELECT COALESCE(MAX(position),0)+1 FROM buckets))')->execute([$bucketName]);
$bucketId = $this->db->lastInsertId();
$this->addLog('pull', null, 'bucket', 'ok', "created bucket: {$bucketName}");
}
}
// Parse title from first # heading or filename
$title = preg_replace('/\.md$/i', '', $remote['filename']);
$body = $content;
if (preg_match('/^#\s+(.+)\n\n?([\s\S]*)$/m', $content, $m)) {
$title = trim($m[1]);
$body = trim($m[2]);
}
// Ensure #notes tag
if (strpos($body, '#notes') === false) {
$body = rtrim($body) . "\n\n#notes";
}
$tags = $this->extractTags($body);
$color = $noteColor ?: ($card['color'] ?? '#38bdf8');
if ($card) {
$this->db->prepare('UPDATE cards SET title=?, body=?, tags=?, color=?, bucket_id=?, caldav_etag=?, caldav_synced_at=datetime("now"), updated_at=datetime("now") WHERE id=?')
->execute([$title, $body, $tags, $color, $bucketId, $remote['etag'], $card['id']]);
$this->addLog('pull', $card['id'], 'update', 'ok', $remote['filename']);
} else {
$this->db->prepare('INSERT INTO cards (title, body, color, tags, bucket_id, position, caldav_uid, caldav_etag, caldav_href, caldav_synced_at) VALUES (?, ?, ?, ?, ?, -1, ?, ?, ?, datetime("now"))')
->execute([$title, $body, $color, $tags, $bucketId, 'note:' . $remote['filename'], $remote['etag'], $remote['filename']]);
$this->addLog('pull', (int)$this->db->lastInsertId(), 'create', 'ok', $remote['filename'] . ($bucketName ? " → {$bucketName}" : ''));
}
$this->stats['pulled']++;
}
private function extractTags(string $body): string
{
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body, $matches);
return implode(',', array_unique(array_map('strtolower', $matches[1] ?? [])));
}
}