-
Notifications
You must be signed in to change notification settings - Fork 5
Fix Web2 link meta fetch interrupting user input and automatically bumping the cursor to the next input field #516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,7 +9,7 @@ | |||||||||||||||||||||||||||||||||||||||
| autovalidate | ||||||||||||||||||||||||||||||||||||||||
| @invalid="() => (isValidLink = false)" | ||||||||||||||||||||||||||||||||||||||||
| @input="handleInput" | ||||||||||||||||||||||||||||||||||||||||
| type="url" | ||||||||||||||||||||||||||||||||||||||||
| type="text" | ||||||||||||||||||||||||||||||||||||||||
| required | ||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||
| <j-box pr="300" v-if="loadingMeta" slot="end"> | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -72,27 +72,64 @@ const loadingMeta = ref(false); | |||||||||||||||||||||||||||||||||||||||
| const isAddingLink = ref(false); | ||||||||||||||||||||||||||||||||||||||||
| const isValidLink = ref(false); | ||||||||||||||||||||||||||||||||||||||||
| const titleEl = ref<HTMLElement | null>(null); | ||||||||||||||||||||||||||||||||||||||||
| let metaDebounce: ReturnType<typeof setTimeout> | null = null; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| function normalizeUrl(input: string): string { | ||||||||||||||||||||||||||||||||||||||||
| const value = (input || "").trim(); | ||||||||||||||||||||||||||||||||||||||||
| if (!value) return value; | ||||||||||||||||||||||||||||||||||||||||
| // If already has a scheme, keep as is | ||||||||||||||||||||||||||||||||||||||||
| const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value); | ||||||||||||||||||||||||||||||||||||||||
| return hasScheme ? value : `https://${value}`; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+77
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Allow-list http/https in normalizeUrl Match WebLinkCard hardening; block non-web schemes early. -function normalizeUrl(input: string): string {
- const value = (input || "").trim();
- if (!value) return value;
- // If already has a scheme, keep as is
- const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value);
- return hasScheme ? value : `https://${value}`;
-}
+function normalizeUrl(input: string): string {
+ const value = (input || "").trim();
+ if (!value) return "";
+ const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value);
+ const withScheme = hasScheme ? value : `https://${value}`;
+ try {
+ const u = new URL(withScheme);
+ return u.protocol === "http:" || u.protocol === "https:" ? withScheme : "";
+ } catch {
+ return "";
+ }
+}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| function looksLikeHost(urlStr: string): boolean { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| const u = new URL(urlStr); | ||||||||||||||||||||||||||||||||||||||||
| const host = u.hostname; | ||||||||||||||||||||||||||||||||||||||||
| // Require a dot or be localhost to reduce early validations like "https://exa" | ||||||||||||||||||||||||||||||||||||||||
| return host === "localhost" || host.includes("."); | ||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| async function getMeta() { | ||||||||||||||||||||||||||||||||||||||||
| async function getMeta(urlForMeta: string) { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| loadingMeta.value = true; | ||||||||||||||||||||||||||||||||||||||||
| const data = await fetch("https://jsonlink.io/api/extract?url=" + link.value).then((res) => res.json()); | ||||||||||||||||||||||||||||||||||||||||
| const data = await fetch("https://jsonlink.io/api/extract?url=" + encodeURIComponent(urlForMeta)).then((res) => | ||||||||||||||||||||||||||||||||||||||||
| res.json() | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| title.value = data.title || ""; | ||||||||||||||||||||||||||||||||||||||||
| description.value = data.description || ""; | ||||||||||||||||||||||||||||||||||||||||
| imageUrl.value = data.images[0] || ""; | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+96
to
105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Prevent stale meta overwrites; make images access safe; only fetch on http(s) Concurrent fetches can race and older responses can clobber newer input; also data.images may be undefined; and we should skip meta for non-http(s). -async function getMeta(urlForMeta: string) {
+async function getMeta(urlForMeta: string, reqId: number) {
try {
loadingMeta.value = true;
- const data = await fetch("https://jsonlink.io/api/extract?url=" + encodeURIComponent(urlForMeta)).then((res) =>
- res.json()
- );
+ const data = await fetch(
+ "https://jsonlink.io/api/extract?url=" + encodeURIComponent(urlForMeta)
+ ).then((res) => res.json());
+
+ // Drop if a newer request started after this one
+ if (reqId !== metaReqId) return;
title.value = data.title || "";
description.value = data.description || "";
- imageUrl.value = data.images[0] || "";
+ imageUrl.value = (data.images?.[0] as string | undefined) || "";
} finally {
loadingMeta.value = false;
}
}
@@
- // Validate
- // new URL will throw if not valid
+ // Validate (throws if invalid)
// We intentionally do not mutate the visible input to avoid caret jumps
// Use a debounced meta fetch to avoid interrupting typing
- new URL(normalized);
- isValidLink.value = true;
+ const u = new URL(normalized);
+ // Only accept http(s)
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
+ isValidLink.value = false;
+ if (metaDebounce) clearTimeout(metaDebounce);
+ return;
+ }
+ isValidLink.value = true;
if (metaDebounce) clearTimeout(metaDebounce);
- metaDebounce = setTimeout(() => {
- getMeta(normalized);
+ metaReqId += 1;
+ const reqId = metaReqId;
+ metaDebounce = setTimeout(() => {
+ getMeta(normalized, reqId);
}, 500);Also applies to: 123-133 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||
| loadingMeta.value = false; | ||||||||||||||||||||||||||||||||||||||||
| titleEl.value?.focus(); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| async function handleInput(e: any) { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| link.value = e.target.value; | ||||||||||||||||||||||||||||||||||||||||
| await new URL(e.target.value); | ||||||||||||||||||||||||||||||||||||||||
| getMeta(); | ||||||||||||||||||||||||||||||||||||||||
| const normalized = normalizeUrl(link.value); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // If it doesn't look like a host yet, consider invalid and skip meta fetch | ||||||||||||||||||||||||||||||||||||||||
| if (!looksLikeHost(normalized)) { | ||||||||||||||||||||||||||||||||||||||||
| isValidLink.value = false; | ||||||||||||||||||||||||||||||||||||||||
| if (metaDebounce) clearTimeout(metaDebounce); | ||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Validate | ||||||||||||||||||||||||||||||||||||||||
| // new URL will throw if not valid | ||||||||||||||||||||||||||||||||||||||||
| // We intentionally do not mutate the visible input to avoid caret jumps | ||||||||||||||||||||||||||||||||||||||||
| // Use a debounced meta fetch to avoid interrupting typing | ||||||||||||||||||||||||||||||||||||||||
| new URL(normalized); | ||||||||||||||||||||||||||||||||||||||||
| isValidLink.value = true; | ||||||||||||||||||||||||||||||||||||||||
| if (metaDebounce) clearTimeout(metaDebounce); | ||||||||||||||||||||||||||||||||||||||||
| metaDebounce = setTimeout(() => { | ||||||||||||||||||||||||||||||||||||||||
| getMeta(normalized); | ||||||||||||||||||||||||||||||||||||||||
| }, 500); | ||||||||||||||||||||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||||||||||||||||||||
| isValidLink.value = false; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -108,7 +145,7 @@ async function createLink() { | |||||||||||||||||||||||||||||||||||||||
| title: title.value, | ||||||||||||||||||||||||||||||||||||||||
| description: description.value, | ||||||||||||||||||||||||||||||||||||||||
| imageUrl: imageUrl.value, | ||||||||||||||||||||||||||||||||||||||||
| url: link.value, | ||||||||||||||||||||||||||||||||||||||||
| url: normalizeUrl(link.value), | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| appStore.showSuccessToast({ message: "Link added to agent perspective" }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,7 +6,7 @@ | |||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| <div class="link-card__content"> | ||||||||||||||||||||||||||||||||||||||
| <a :href="url" target="_blank" class="link-card__info"> | ||||||||||||||||||||||||||||||||||||||
| <a :href="safeUrl" target="_blank" rel="noopener noreferrer" class="link-card__info"> | ||||||||||||||||||||||||||||||||||||||
| <h2 class="link-card__title">{{ title || url }}</h2> | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| <div class="link-card__description" v-if="description">{{ description }}</div> | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -39,10 +39,19 @@ const emit = defineEmits(["delete", "edit"]); | |||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const { ad4mClient } = useAppStore(); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| function normalizeUrl(input: string): string { | ||||||||||||||||||||||||||||||||||||||
| const value = (input || "").trim(); | ||||||||||||||||||||||||||||||||||||||
| if (!value) return ""; | ||||||||||||||||||||||||||||||||||||||
| const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value); | ||||||||||||||||||||||||||||||||||||||
| return hasScheme ? value : `https://${value}`; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+42
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Allow-list http/https to block javascript: and other unsafe schemes Current normalizeUrl passes through any scheme, enabling clickable javascript: URLs. Restrict to http(s) and return empty for others. -function normalizeUrl(input: string): string {
- const value = (input || "").trim();
- if (!value) return "";
- const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value);
- return hasScheme ? value : `https://${value}`;
-}
+function normalizeUrl(input: string): string {
+ const value = (input || "").trim();
+ if (!value) return "";
+ const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value);
+ const withScheme = hasScheme ? value : `https://${value}`;
+ try {
+ const u = new URL(withScheme);
+ return u.protocol === "http:" || u.protocol === "https:" ? withScheme : "";
+ } catch {
+ return "";
+ }
+}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const safeUrl = computed(() => normalizeUrl(props.url)); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const hostname = computed(() => { | ||||||||||||||||||||||||||||||||||||||
| if (props.url) { | ||||||||||||||||||||||||||||||||||||||
| if (safeUrl.value) { | ||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| return new URL(props.url).hostname; | ||||||||||||||||||||||||||||||||||||||
| return new URL(safeUrl.value).hostname; | ||||||||||||||||||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||||||||||||||||||
| return ""; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Clear debounce on unmount and track requests to avoid leaks/races
Ensure pending timers don’t fire after unmount and prep a request id for stale-response protection.
Add (outside this block) to imports and lifecycle:
🤖 Prompt for AI Agents