diff --git a/app/Http/Controllers/Api/LinkPreviewController.php b/app/Http/Controllers/Api/LinkPreviewController.php new file mode 100644 index 0000000..ec75888 --- /dev/null +++ b/app/Http/Controllers/Api/LinkPreviewController.php @@ -0,0 +1,180 @@ +validate([ + 'url' => 'required|url', + ]); + + $url = $request->input('url'); + + $host = strtolower(parse_url($url, PHP_URL_HOST) ?? ''); + $localHosts = ['localhost', '127.0.0.1', '127.0.0.2', '::1', strtolower(parse_url(config('app.url', ''), PHP_URL_HOST) ?? '')]; + + $serverHost = strtolower(parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST) ?? ''); + if ($serverHost) { + $localHosts[] = $serverHost; + } + + if (in_array($host, $localHosts)) { + return response()->json([ + 'url' => $url, + 'title' => $host ?: 'Local Link', + 'description' => 'Link to local page', + 'image' => null, + 'favicon' => asset('favicon.ico'), + ]); + } + + // Cache the preview for 24 hours + $cacheKey = 'link_preview_' . md5($url); + + $data = Cache::remember($cacheKey, now()->addDay(), function () use ($url) { + try { + return $this->fetchPreviewData($url); + } catch (\Exception $e) { + // Cache the fallback response on failure to prevent repeated timeouts + // that could exhaust server workers if a link is dead. + return [ + 'url' => $url, + 'title' => parse_url($url, PHP_URL_HOST), + 'description' => 'Failed to fetch link preview', + 'image' => null, + 'favicon' => asset('favicon.ico'), + ]; + } + }); + + return response()->json($data); + } + + private function fetchPreviewData(string $url): array + { + // Set short timeout to prevent blocking server threads + $response = Http::timeout(3) + ->withHeaders([ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ]) + ->get($url); + + if (!$response->successful()) { + throw new \Exception('HTTP request failed with status ' . $response->status()); + } + + $contentType = $response->header('Content-Type', ''); + if (strpos($contentType, 'text/html') === false) { + return [ + 'url' => $url, + 'title' => basename($url) ?: parse_url($url, PHP_URL_HOST), + 'description' => 'Link to file: ' . $contentType, + 'image' => null, + 'favicon' => asset('favicon.ico'), + ]; + } + + $html = $response->body(); + + // Only parse the section (or first 150KB) to save memory and CPU + $headEnd = stripos($html, ''); + if ($headEnd !== false) { + $html = substr($html, 0, $headEnd + 7); + } else { + $html = substr($html, 0, 150000); + } + + // Parse HTML using DOMDocument + $doc = new \DOMDocument(); + // Suppress HTML parsing warnings + @$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_NOERROR | LIBXML_NOWARNING); + + $xpath = new \DOMXPath($doc); + + // Extract metadata + $title = $this->getXpathContent($xpath, [ + '//meta[@property="og:title"]/@content', + '//meta[@name="twitter:title"]/@content', + '//title', + ]) ?: parse_url($url, PHP_URL_HOST); + + $description = $this->getXpathContent($xpath, [ + '//meta[@property="og:description"]/@content', + '//meta[@name="twitter:description"]/@content', + '//meta[@name="description"]/@content', + ]); + + $image = $this->getXpathContent($xpath, [ + '//meta[@property="og:image"]/@content', + '//meta[@name="twitter:image"]/@content', + ]); + + $favicon = $this->getXpathContent($xpath, [ + '//link[@rel="icon"]/@href', + '//link[@rel="shortcut icon"]/@href', + '//link[@rel="apple-touch-icon"]/@href', + ]); + + // Resolve relative paths to absolute URLs + if ($image) { + $image = $this->resolveUrl($image, $url); + } + if ($favicon) { + $favicon = $this->resolveUrl($favicon, $url); + } else { + $favicon = asset('favicon.ico'); + } + + return [ + 'url' => $url, + 'title' => trim($title), + 'description' => trim($description), + 'image' => $image, + 'favicon' => $favicon, + ]; + } + + private function getXpathContent(\DOMXPath $xpath, array $queries): ?string + { + foreach ($queries as $query) { + $nodes = $xpath->query($query); + if ($nodes && $nodes->length > 0) { + return $nodes->item(0)->nodeValue; + } + } + return null; + } + + private function resolveUrl(string $path, string $baseUrl): string + { + if (parse_url($path, PHP_URL_SCHEME) !== null) { + return $path; + } + + $parts = parse_url($baseUrl); + $domain = $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : ''); + + if (strpos($path, '//') === 0) { + return $parts['scheme'] . ':' . $path; + } + + if (strpos($path, '/') === 0) { + return $domain . $path; + } + + $basePath = isset($parts['path']) ? $parts['path'] : '/'; + if (substr($basePath, -1) !== '/') { + $basePath = dirname($basePath) . '/'; + } + + return $domain . $basePath . $path; + } + +} diff --git a/package-lock.json b/package-lock.json index 8562cea..fe53655 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "dependencies": { "@inertiajs/vite": "^3.0.0", "@inertiajs/vue3": "^3.0.0", + "@tiptap/core": "^3.22.3", + "@tiptap/extension-link": "^3.22.3", + "@tiptap/extension-paragraph": "^3.22.3", "@tiptap/extension-placeholder": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", "@tiptap/vue-3": "^3.22.3", @@ -1411,12 +1414,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@remirror/core-constants": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", - "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.50", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", @@ -2412,30 +2409,25 @@ } }, "node_modules/@tiptap/pm": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.3.tgz", - "integrity": "sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==", + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.1.tgz", + "integrity": "sha512-48cJQRbvr9Ux0+IgM1BR5vOLU5hkC+n+uerdQy2JjrIRKpYE/huU8fQFm6PoRppoKYfilklzb29elsQ+n2TA+g==", "license": "MIT", "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", - "prosemirror-collab": "^1.3.1", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", - "prosemirror-keymap": "^1.2.2", - "prosemirror-markdown": "^1.13.1", - "prosemirror-menu": "^1.2.4", - "prosemirror-model": "^1.24.1", - "prosemirror-schema-basic": "^1.2.3", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", "prosemirror-schema-list": "^1.5.0", - "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.6.4", - "prosemirror-trailing-node": "^3.0.0", - "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.38.1" + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.0", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8" }, "funding": { "type": "github", @@ -2511,28 +2503,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", @@ -3214,6 +3184,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -3495,12 +3466,6 @@ "dev": true, "license": "MIT" }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3741,6 +3706,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4834,19 +4800,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, "node_modules/linkifyjs": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", - "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", "license": "MIT" }, "node_modules/locate-path": { @@ -4907,29 +4864,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5375,15 +5309,6 @@ "prosemirror-transform": "^1.0.0" } }, - "node_modules/prosemirror-collab": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", - "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-commands": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", @@ -5450,48 +5375,15 @@ "w3c-keyname": "^2.2.0" } }, - "node_modules/prosemirror-markdown": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", - "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", - "license": "MIT", - "dependencies": { - "@types/markdown-it": "^14.0.0", - "markdown-it": "^14.0.0", - "prosemirror-model": "^1.25.0" - } - }, - "node_modules/prosemirror-menu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz", - "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==", - "license": "MIT", - "dependencies": { - "crelt": "^1.0.0", - "prosemirror-commands": "^1.0.0", - "prosemirror-history": "^1.0.0", - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-model": { - "version": "1.25.4", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", - "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "version": "1.25.9", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz", + "integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } }, - "node_modules/prosemirror-schema-basic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", - "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", - "license": "MIT", - "dependencies": { - "prosemirror-model": "^1.25.0" - } - }, "node_modules/prosemirror-schema-list": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", @@ -5508,7 +5400,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -5528,21 +5419,6 @@ "prosemirror-view": "^1.41.4" } }, - "node_modules/prosemirror-trailing-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", - "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", - "license": "MIT", - "dependencies": { - "@remirror/core-constants": "3.0.0", - "escape-string-regexp": "^4.0.0" - }, - "peerDependencies": { - "prosemirror-model": "^1.22.1", - "prosemirror-state": "^1.4.2", - "prosemirror-view": "^1.33.8" - } - }, "node_modules/prosemirror-transform": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", @@ -5557,7 +5433,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -5574,15 +5449,6 @@ "node": ">=6" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6117,12 +5983,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 2abd138..932a1c5 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "dependencies": { "@inertiajs/vite": "^3.0.0", "@inertiajs/vue3": "^3.0.0", + "@tiptap/core": "^3.22.3", + "@tiptap/extension-link": "^3.22.3", + "@tiptap/extension-paragraph": "^3.22.3", "@tiptap/extension-placeholder": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", "@tiptap/vue-3": "^3.22.3", diff --git a/resources/js/components/tasks/LinkPreviewCard.vue b/resources/js/components/tasks/LinkPreviewCard.vue new file mode 100644 index 0000000..a1fa12e --- /dev/null +++ b/resources/js/components/tasks/LinkPreviewCard.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/resources/js/components/tasks/ParagraphPreviewNodeView.vue b/resources/js/components/tasks/ParagraphPreviewNodeView.vue new file mode 100644 index 0000000..ccbea18 --- /dev/null +++ b/resources/js/components/tasks/ParagraphPreviewNodeView.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/resources/js/components/tasks/TaskEditFormPanel.vue b/resources/js/components/tasks/TaskEditFormPanel.vue index 92f4d4d..6de1b42 100644 --- a/resources/js/components/tasks/TaskEditFormPanel.vue +++ b/resources/js/components/tasks/TaskEditFormPanel.vue @@ -113,7 +113,7 @@ const onTagsUpdated = (tags: Tag[]) => { -
+