fix(api,frontend): isolate active share files and allowlist link schemes
This commit is contained in:
@@ -76,6 +76,18 @@ export function isPreviewable(kind: string, mime: string | null): boolean {
|
||||
return PREVIEW_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix));
|
||||
}
|
||||
|
||||
const ACTIVE_MIMES = new Set([
|
||||
"text/html",
|
||||
"application/xhtml+xml",
|
||||
"image/svg+xml",
|
||||
"text/xml",
|
||||
"application/xml",
|
||||
]);
|
||||
|
||||
export function isActiveContent(mime: string | null): boolean {
|
||||
return ACTIVE_MIMES.has(mime?.split(";", 1)[0].trim().toLowerCase() ?? "");
|
||||
}
|
||||
|
||||
export type VisualKind = "image" | "video" | "other";
|
||||
|
||||
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { InlineButton } from "$lib/api/types";
|
||||
import { safeHref } from "$lib/format/url";
|
||||
|
||||
interface Props {
|
||||
rows: InlineButton[][];
|
||||
@@ -26,7 +27,12 @@
|
||||
{#each row as button, colIndex (colIndex)}
|
||||
{@const key = `${rowIndex}:${colIndex}`}
|
||||
{#if button.kind === "url" && button.url}
|
||||
<a class="button" href={button.url} target="_blank" rel="noopener">
|
||||
<a
|
||||
class="button"
|
||||
href={safeHref(button.url)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="label">{button.text}</span>
|
||||
<span class="corner">↗</span>
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { type MediaResult, requestMediaVersion } from "$lib/api/client";
|
||||
import { visualKind } from "$lib/api/media";
|
||||
import { isActiveContent, visualKind } from "$lib/api/media";
|
||||
import type { MediaVersion } from "$lib/api/types";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
@@ -48,7 +48,13 @@
|
||||
<span class="play"><Icon name="large-play" size="1.5rem" /></span>
|
||||
</a>
|
||||
{:else if result.state === "ready"}
|
||||
<a class="file" href={result.url} target="_blank" rel="noopener">
|
||||
<a
|
||||
class="file"
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
download={isActiveContent(result.mime) ? `media_${version.id}` : undefined}
|
||||
>
|
||||
<Icon name="document" size="1.125rem" />
|
||||
<span>{version.kind}</span>
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import MessageMedia from "$lib/components/MessageMedia.svelte";
|
||||
import { safeHref } from "$lib/format/url";
|
||||
|
||||
interface Props {
|
||||
message: MessageView;
|
||||
@@ -20,7 +21,7 @@
|
||||
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
|
||||
</div>
|
||||
{/if}
|
||||
<a class="card" href={web.url} target="_blank" rel="noopener">
|
||||
<a class="card" href={safeHref(web.url)} target="_blank" rel="noopener">
|
||||
{#if web.site_name}
|
||||
<div class="site">{web.site_name}</div>
|
||||
{:else if web.display_url}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { formatListDate } from "$lib/format/datetime";
|
||||
import { safeHref } from "$lib/format/url";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { ui } from "$lib/stores/ui.svelte";
|
||||
|
||||
@@ -94,7 +95,7 @@
|
||||
<span class="title">{item.web_title ?? host(item.url)}</span>
|
||||
<a
|
||||
class="url"
|
||||
href={item.url}
|
||||
href={safeHref(item.url)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { safeHref } from "$lib/format/url";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { ui } from "$lib/stores/ui.svelte";
|
||||
|
||||
@@ -69,7 +70,12 @@
|
||||
{#if item.web_title}
|
||||
<span class="title">{item.web_title}</span>
|
||||
{/if}
|
||||
<a class="url" href={item.url} target="_blank" rel="noopener">
|
||||
<a
|
||||
class="url"
|
||||
href={safeHref(item.url)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{item.url}
|
||||
</a>
|
||||
{#if item.web_site_name || item.web_description}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EntityView } from "$lib/api/types";
|
||||
import { safeHref } from "$lib/format/url";
|
||||
|
||||
export interface TextNode {
|
||||
kind: "text";
|
||||
@@ -61,12 +62,6 @@ export function buildEntityTree(
|
||||
return buildNodes(text, sorted, 0, text.length);
|
||||
}
|
||||
|
||||
const PROTOCOL_RE = /^[a-z]+:/i;
|
||||
|
||||
function ensureProtocol(url: string): string {
|
||||
return PROTOCOL_RE.test(url) ? url : `https://${url}`;
|
||||
}
|
||||
|
||||
export function nodeText(node: EntityTreeNode): string {
|
||||
if (node.kind === "text") {
|
||||
return node.text;
|
||||
@@ -74,10 +69,10 @@ export function nodeText(node: EntityTreeNode): string {
|
||||
return node.children.map(nodeText).join("");
|
||||
}
|
||||
|
||||
export function linkHref(node: EntityNode): string {
|
||||
export function linkHref(node: EntityNode): string | undefined {
|
||||
const { entity } = node;
|
||||
if (entity.type === "text_link" && entity.url) {
|
||||
return ensureProtocol(entity.url);
|
||||
return safeHref(entity.url);
|
||||
}
|
||||
if (entity.type === "email") {
|
||||
return `mailto:${nodeText(node)}`;
|
||||
@@ -85,7 +80,7 @@ export function linkHref(node: EntityNode): string {
|
||||
if (entity.type === "phone_number") {
|
||||
return `tel:${nodeText(node)}`;
|
||||
}
|
||||
return ensureProtocol(nodeText(node));
|
||||
return safeHref(nodeText(node));
|
||||
}
|
||||
|
||||
const EMOJI_RE = /\p{Extended_Pictographic}/u;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const SCHEME_RE = /^([a-z][a-z0-9+.-]*):/i;
|
||||
const SAFE_SCHEMES = new Set(["http", "https", "tg", "ton", "mailto", "tel"]);
|
||||
|
||||
export function safeHref(url: string | null | undefined): string | undefined {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
const scheme = SCHEME_RE.exec(trimmed)?.[1].toLowerCase();
|
||||
if (scheme === undefined) {
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
return SAFE_SCHEMES.has(scheme) ? trimmed : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user