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
357 changes: 197 additions & 160 deletions apps/FRONTEND_AUDIT.md

Large diffs are not rendered by default.

179 changes: 127 additions & 52 deletions apps/packages/ui/src/components/Common/CharacterSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import {
Dropdown,
Tooltip,
Expand Down Expand Up @@ -43,6 +43,11 @@ import {
buildCharactersRoute as buildCharactersRouteUrl,
resolveCharactersDestinationMode
} from "@/utils/characters-route"
import {
readFavoriteFromExtensions,
applyFavoriteToExtensions,
MAX_IMPORT_FILE_BYTES
} from "@/components/Option/Characters/utils"

type Props = {
className?: string
Expand Down Expand Up @@ -104,12 +109,6 @@ type CharacterSelection = {

type CharacterSortMode = "favorites" | "az"

type FavoriteCharacter = {
id?: string
slug?: string
name: string
}

const MAX_PERSONA_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_MOOD_IMAGE_BYTES = 5 * 1024 * 1024

Expand Down Expand Up @@ -167,6 +166,7 @@ export const CharacterSelect: React.FC<Props> = ({
}) => {
const { t } = useTranslation(["option", "common", "settings", "playground"])
const notification = useAntdNotification()
const queryClient = useQueryClient()
const modal = useAntdModal()
const confirmWithModal = useConfirmModal()
const [selectedCharacter, setSelectedCharacter] =
Expand Down Expand Up @@ -241,10 +241,6 @@ export const CharacterSelect: React.FC<Props> = ({
"menuDensity",
"comfortable"
)
const [favoriteCharacters, setFavoriteCharacters] = useStorage<FavoriteCharacter[]>(
"favoriteCharacters",
[]
)
const [sortMode, setSortMode] = useStorage<CharacterSortMode>(
"characterSortMode",
"favorites"
Expand Down Expand Up @@ -1023,6 +1019,21 @@ export const CharacterSelect: React.FC<Props> = ({
const file = event.target.files?.[0]
if (!file) return

if (file.size > MAX_IMPORT_FILE_BYTES) {
notification.error({
message: t("settings:manageCharacters.notification.error", {
defaultValue: "Error"
}),
description: t("settings:manageCharacters.import.tooLarge", {
defaultValue:
"This file is too large to import. Please choose a smaller file (under {{maxMb}} MB).",
maxMb: Math.floor(MAX_IMPORT_FILE_BYTES / (1024 * 1024))
})
})
event.target.value = ""
return
}

const getImageOnlyDetail = (error: unknown): ImageOnlyErrorDetail | null => {
const details = (error as ImportError)?.details
if (!details || typeof details !== "object") return null
Expand Down Expand Up @@ -1137,18 +1148,6 @@ export const CharacterSelect: React.FC<Props> = ({
})
}, [data, searchQuery])

const favoriteIndex = React.useMemo(() => {
const ids = new Set<string>()
const slugs = new Set<string>()
const names = new Set<string>()
;(favoriteCharacters || []).forEach((fav) => {
if (fav.id) ids.add(String(fav.id))
if (fav.slug) slugs.add(String(fav.slug))
if (fav.name) names.add(String(fav.name))
})
return { ids, slugs, names }
}, [favoriteCharacters])

const getCharacterDisplayName = React.useCallback((character: CharacterSummary) => {
return (
character.name ||
Expand All @@ -1158,41 +1157,117 @@ export const CharacterSelect: React.FC<Props> = ({
).toString()
}, [])

// Favorite is a server-side flag (extensions.tldw.favorite) so the header and
// the Characters Manager share a single source of truth instead of a stale
// localStorage mirror.
const isFavoriteCharacter = React.useCallback(
(character: CharacterSummary) => {
const id = character.id != null ? String(character.id) : ""
const slug = character.slug ? String(character.slug) : ""
const name = getCharacterDisplayName(character)
return (
(id && favoriteIndex.ids.has(id)) ||
(slug && favoriteIndex.slugs.has(slug)) ||
(name && favoriteIndex.names.has(name))
)
},
[favoriteIndex, getCharacterDisplayName]
(character: CharacterSummary) =>
readFavoriteFromExtensions(character.extensions),
[]
)

const toggleFavoriteCharacter = React.useCallback(
(character: CharacterSummary) => {
const name = getCharacterDisplayName(character).trim()
const id = character.id != null ? String(character.id) : undefined
const slug = character.slug ? String(character.slug) : undefined
if (!name) return
void setFavoriteCharacters((prev) => {
const list = Array.isArray(prev) ? prev : []
const next = list.filter((fav) => {
if (id && fav.id && fav.id === id) return false
if (slug && fav.slug && fav.slug === slug) return false
if (name && fav.name === name) return false
return true
async (character: CharacterSummary) => {
const id =
character.id != null
? String(character.id)
: character.slug
? String(character.slug)
: ""
if (!id) return

const nextFavorite = !readFavoriteFromExtensions(character.extensions)
const nextExtensions = applyFavoriteToExtensions(
character.extensions,
nextFavorite
)
if (nextExtensions === null) {
notification.warning({
message: t(
"settings:manageCharacters.notification.favoriteInvalidExtensions",
{
defaultValue: "Couldn't update favorite"
}
),
description: t(
"settings:manageCharacters.notification.favoriteInvalidExtensionsDesc",
{
defaultValue:
"This character has invalid extensions JSON. Fix the extensions field before toggling favorite."
}
)
})
if (next.length === list.length) {
next.push({ id, slug, name })
return
}

const matchesCharacter = (candidate: any) => {
const candidateId =
candidate?.id != null
? String(candidate.id)
: candidate?.slug
? String(candidate.slug)
: ""
return candidateId === id
}
const applyToItem = (candidate: any) =>
matchesCharacter(candidate)
? { ...candidate, extensions: nextExtensions ?? {} }
: candidate
const updateCachedList = (old: any): any => {
if (Array.isArray(old)) return old.map(applyToItem)
if (old && Array.isArray(old.items)) {
return { ...old, items: old.items.map(applyToItem) }
}
return next
})
return old
}

// Optimistically update every cached character list (bare header key and
// the Manager's 3-element paginated keys) by prefix match.
let previousEntries: [readonly unknown[], unknown][] = []
try {
previousEntries =
(queryClient.getQueriesData?.({
queryKey: ["tldw:listCharacters"]
}) as [readonly unknown[], unknown][] | undefined) ?? []
queryClient.setQueriesData?.(
{ queryKey: ["tldw:listCharacters"] },
updateCachedList
)
} catch {
// Optimistic update not available
}

try {
await tldwClient.initialize().catch(() => null)
await tldwClient.updateCharacter(
id,
{ extensions: nextExtensions ?? {} },
character.version
)
queryClient.invalidateQueries({ queryKey: ["tldw:listCharacters"] })
} catch (error) {
for (const [key, prev] of previousEntries) {
try {
queryClient.setQueryData?.(key, prev)
} catch {
// noop
}
}
const messageText =
error instanceof Error ? error.message : String(error)
notification.error({
message: t("settings:manageCharacters.notification.error", {
defaultValue: "Error"
}),
description:
messageText ||
t("settings:manageCharacters.notification.someError", {
defaultValue: "Something went wrong. Please try again later"
})
})
}
},
[getCharacterDisplayName, setFavoriteCharacters]
[notification, queryClient, t]
)

const sortedCharacters = React.useMemo(() => {
Expand Down Expand Up @@ -1260,7 +1335,7 @@ export const CharacterSelect: React.FC<Props> = ({
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoriteCharacter(character)
void toggleFavoriteCharacter(character)
}}
aria-label={favoriteTitle}
title={favoriteTitle}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { KnowledgeIcon } from "@/components/Option/Knowledge/KnowledgeIcon"
import { safeExternalUrl } from "@/utils/safe-external-url"
import { useTranslation } from "react-i18next"
import React from "react"

Expand Down Expand Up @@ -78,6 +79,7 @@ export const MessageSource: React.FC<Props> = ({
source?.snippet ||
""
const url = source?.url
const safeUrl = safeExternalUrl(url)
const page = source?.metadata?.page
const lineFrom = source?.metadata?.loc?.lines?.from
const lineTo = source?.metadata?.loc?.lines?.to
Expand Down Expand Up @@ -177,10 +179,10 @@ export const MessageSource: React.FC<Props> = ({
}, [emitDwell])

if (!isExpandable) {
if (url) {
if (safeUrl) {
return (
<a
href={url}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => {
Expand Down Expand Up @@ -254,9 +256,9 @@ export const MessageSource: React.FC<Props> = ({
{`Line ${lineFrom} - ${lineTo}`}
</span>
)}
{url && (
{safeUrl && (
<a
href={url}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from "react"
import { useTranslation } from "react-i18next"
import { tldwClient } from "@/services/tldw/TldwApiClient"
import type { RagSettings } from "@/services/rag/unified-rag"
import { openExternalUrl } from "@/utils/safe-external-url"
import {
formatRagResult,
type RagCopyFormat,
Expand Down Expand Up @@ -219,7 +220,7 @@ export function useFileSearch({
const handleOpen = React.useCallback((item: RagResult) => {
const url = getResultUrl(item)
if (!url) return
window.open(String(url), "_blank")
openExternalUrl(String(url), "_blank")
}, [])

const handlePin = React.useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useStorage } from "@plasmohq/storage/hook"
import { shallow } from "zustand/shallow"
import { tldwClient } from "@/services/tldw/TldwApiClient"
import { type RagSettings } from "@/services/rag/unified-rag"
import { openExternalUrl } from "@/utils/safe-external-url"
import {
formatRagResult,
type RagCopyFormat,
Expand Down Expand Up @@ -617,7 +618,7 @@ export function useKnowledgeSearch({
const handleOpen = React.useCallback((item: RagResult) => {
const url = getResultUrl(item)
if (!url) return
window.open(String(url), "_blank")
openExternalUrl(String(url), "_blank")
}, [])

const handlePin = React.useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ import { describe, expect, it } from "vitest"
import { COCKPIT_LEFT_RESTORE_WRAPPER_CLASS } from "../chat-rail-positioning"

describe("chat rail positioning contract", () => {
it("keeps the cockpit context restore trigger attached to the left edge", () => {
it("keeps the cockpit context restore trigger clear of the app navigation rail", () => {
expect(COCKPIT_LEFT_RESTORE_WRAPPER_CLASS.split(" ")).toEqual(
expect.arrayContaining([
"fixed",
"absolute",
"left-0",
"top-[clamp(18rem,36vh,24rem)]",
"z-50"
])
)
expect(COCKPIT_LEFT_RESTORE_WRAPPER_CLASS).not.toContain("fixed")
expect(COCKPIT_LEFT_RESTORE_WRAPPER_CLASS).not.toContain("left-12")
expect(COCKPIT_LEFT_RESTORE_WRAPPER_CLASS).not.toContain("top-1/2")
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export const COCKPIT_LEFT_RESTORE_WRAPPER_CLASS =
"fixed left-0 top-[clamp(18rem,36vh,24rem)] z-50 hidden lg:inline-flex"
"absolute left-0 top-[clamp(18rem,36vh,24rem)] z-50 hidden lg:inline-flex"
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest"
import { markdownInlineToHtml } from "../notes-manager-utils"

// `sanitizeUrl` is private; exercise it through `markdownInlineToHtml`, which
// renders a markdown link only when the URL survives sanitization.
describe("markdownInlineToHtml URL sanitization", () => {
it("keeps safe http(s) links", () => {
const html = markdownInlineToHtml("[site](https://example.com)")
expect(html).toContain('<a href="https://example.com">')
})

it("neutralizes javascript: links", () => {
const html = markdownInlineToHtml("[x](javascript:alert(1))")
expect(html).not.toContain("<a ")
expect(html).not.toContain("javascript:")
})

it("neutralizes control-char obfuscated schemes (java\\tscript:)", () => {
const html = markdownInlineToHtml("[x](java\tscript:alert(1))")
expect(html).not.toContain("<a ")
// The tab is stripped before scheme matching, so it is caught and dropped.
expect(html).not.toContain("script:")
})

it("neutralizes newline obfuscated schemes", () => {
const html = markdownInlineToHtml("[x](java\nscript:alert(1))")
expect(html).not.toContain("<a ")
expect(html).not.toContain("script:")
})
})
11 changes: 10 additions & 1 deletion apps/packages/ui/src/components/Notes/notes-manager-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,16 @@ export const escapeHtml = (value: string): string =>
const SAFE_URL_PROTOCOLS = /^(https?:|mailto:|tel:|note:|#|\/)/i

const sanitizeUrl = (url: string): string => {
const trimmed = url.trim()
// Strip C0 control characters (incl. tab/newline/CR) and DEL first: browsers
// remove them when resolving a URL, so `java\tscript:` would otherwise match
// neither branch below and be emitted verbatim as an executable scheme.
let stripped = ''
for (const ch of url) {
const code = ch.charCodeAt(0)
if (code <= 0x1f || code === 0x7f) continue
stripped += ch
}
const trimmed = stripped.trim()
if (!trimmed) return ''
if (SAFE_URL_PROTOCOLS.test(trimmed)) return trimmed
// Block javascript:, data:, vbscript:, etc.
Expand Down
Loading
Loading