bak
This commit is contained in:
1 parent
5aa3abc860
commit
5f9db690a4
24 files changed
+1412
-562
No files matched your search
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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:
|
||||
@@ -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,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
@@ -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: , , 
|
||||
$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:  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
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: ``, 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');
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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]
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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] ?? [])));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user