Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,36 @@ paths:
responses:
"200":
$ref: "#/components/responses/JsonOk"
/links/metadata:
get:
tags:
- links
summary: Get link metadata
parameters:
- name: url
in: query
required: true
schema:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
title:
type: string
description:
type: string
iconUrl:
type: string
required:
- title
- description
- iconUrl
additionalProperties: false
/links/home/groups:
get:
tags:
Expand Down
90 changes: 90 additions & 0 deletions apps/backend/src/lib/api/tools/linkMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
export type LinkMetadata = {
title: string;
description: string;
iconUrl: string;
};

const LINK_METADATA_HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; Dashwise link metadata)",
Accept: "text/html,application/xhtml+xml",
};

function decodeHtmlEntities(value: string) {
return value
.replace(/&/gi, "&")
.replace(/"/gi, '"')
.replace(/'|'/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&#x([\da-f]+);/gi, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 10)));
}

function getAttribute(tag: string, attribute: string) {
const match = tag.match(new RegExp(`\\b${attribute}\\s*=\\s*["']([^"']*)["']`, "i"));
return match?.[1] ? decodeHtmlEntities(match[1]).trim() : "";
}

function getMetaContent(tags: string[], names: string[]) {
for (const tag of tags) {
const name = getAttribute(tag, "property") || getAttribute(tag, "name");
if (names.includes(name.toLowerCase())) {
const content = getAttribute(tag, "content");
if (content) return content;
}
}

return "";
}

function getTitle(html: string) {
const title = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? "";
return decodeHtmlEntities(title).replace(/\s+/g, " ").trim();
}

function getIconUrl(html: string, baseUrl: string) {
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
for (const tag of linkTags) {
const rel = getAttribute(tag, "rel").toLowerCase();
const href = getAttribute(tag, "href");
if (!href || !/(^|\s)(shortcut\s+icon|icon|apple-touch-icon)(\s|$)/i.test(rel)) continue;

try {
return new URL(href, baseUrl).href;
} catch {
// Try the next icon declaration.
}
}

return new URL("/favicon.ico", baseUrl).href;
}

export async function getLinkMetadata(url: string): Promise<LinkMetadata> {
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
throw new Error("Enter a valid URL to fetch link details");
}

if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
throw new Error("Only HTTP and HTTPS URLs are supported");
}

const response = await fetch(parsedUrl, { headers: LINK_METADATA_HEADERS });
if (!response.ok) {
throw new Error(`Unable to fetch URL (${response.status})`);
}

const html = (await response.text()).slice(0, 2_000_000);
const finalUrl = response.url || parsedUrl.href;
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
const title = getMetaContent(metaTags, ["og:title", "twitter:title"]) || getTitle(html);
const description = getMetaContent(metaTags, ["og:description", "twitter:description", "description"]);

return {
title: title || new URL(finalUrl).hostname,
description,
iconUrl: getIconUrl(html, finalUrl),
};
}
6 changes: 6 additions & 0 deletions apps/backend/src/routes/links.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ import { createCollection, createCollectionLinkItem, createHomeLinkGroup, create
import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared";
import { config } from "../lib/config";
import { ApiActionError } from "../lib/data/auth";
import { getLinkMetadata } from "../lib/api/tools/linkMetadata";

const linksRoute = new Hono();

linksRoute
.get("/api/v1/links/metadata", withJson(async (c) => {
await requireAuth({ token: readAuthToken(c) });
return getLinkMetadata(String(c.req.query("url") ?? "").trim());
}))
.get("/api/v1/links/collections", withJson(async (c) => {
const { userId } = await requireAuth({ token: readAuthToken(c) });
return getLinksCollections(userId);
Expand Down Expand Up @@ -55,6 +60,7 @@ linksRoute
list: String(body?.list ?? ""),
name: String(body?.name ?? ""),
parentFolder: typeof body?.parentFolder === "string" ? body.parentFolder : undefined,
icon: typeof body?.icon === "string" ? body.icon : undefined,
});
}))
.get("/api/v1/links/items", withJson(async (c) => {
Expand Down
47 changes: 47 additions & 0 deletions apps/web/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,53 @@
}
}
},
"/links/metadata": {
"get": {
"tags": [
"links"
],
"summary": "Get link metadata",
"parameters": [
{
"name": "url",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"iconUrl": {
"type": "string"
}
},
"required": [
"title",
"description",
"iconUrl"
],
"additionalProperties": false
}
}
}
}
}
}
},
"/links/home/groups": {
"get": {
"tags": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export default function LinksListDetailPage() {

queryClient.setQueryData<LinkItemRecord[]>(["api", token, ...queryKeys.links.items(listId)], (current = []) => [link as LinkItemRecord, ...current.filter((item) => item.id !== link.id)]);
}}
onFolderCreated={(folder) => {
if (folder.list !== listId) return;

queryClient.setQueryData<LinkFolderRecord[]>(["api", token, ...queryKeys.links.folders(listId)], (current = []) => [folder as LinkFolderRecord, ...current.filter((item) => item.id !== folder.id)]);
}}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ export default function LinksTagDetailPage() {

queryClient.setQueryData(["api", token, ...queryKeys.links.tagDetail(tagId)], (current: typeof detailQuery.data) => current ? { ...current, items: [link as LinkItemRecord, ...current.items.filter((item) => item.id !== link.id)] } : current);
}}
onFolderCreated={(folder) => {
queryClient.setQueryData(["api", token, ...queryKeys.links.tagDetail(tagId)], (current: typeof detailQuery.data) => current ? { ...current, folders: [folder as LinkFolderRecord, ...current.folders.filter((item) => item.id !== folder.id)] } : current);
}}
/>
</>
);
Expand Down
Loading
Loading