diff --git a/proto/api/v1/instance_service.proto b/proto/api/v1/instance_service.proto index 35a73e679..836a2415e 100644 --- a/proto/api/v1/instance_service.proto +++ b/proto/api/v1/instance_service.proto @@ -173,6 +173,10 @@ message InstanceSetting { // Tag metadata configuration. message TagsSetting { + // Map of tag name pattern to tag metadata. + // Each key is treated as an anchored regular expression (^pattern$), + // so a single entry like "project/.*" matches all tags under that prefix. + // Exact tag names are also valid (they are trivially valid regex patterns). map tags = 1; } diff --git a/proto/store/instance_setting.proto b/proto/store/instance_setting.proto index 719efb17f..b06a11585 100644 --- a/proto/store/instance_setting.proto +++ b/proto/store/instance_setting.proto @@ -116,6 +116,10 @@ message InstanceTagMetadata { } message InstanceTagsSetting { + // Map of tag name pattern to tag metadata. + // Each key is treated as an anchored regular expression (^pattern$), + // so a single entry like "project/.*" matches all tags under that prefix. + // Exact tag names are also valid (they are trivially valid regex patterns). map tags = 1; } diff --git a/server/router/api/v1/instance_service.go b/server/router/api/v1/instance_service.go index efa5babf3..de123cfc6 100644 --- a/server/router/api/v1/instance_service.go +++ b/server/router/api/v1/instance_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math" + "regexp" "strings" "github.com/pkg/errors" @@ -391,6 +392,9 @@ func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) erro if strings.TrimSpace(tag) == "" { return errors.New("tag key cannot be empty") } + if _, err := regexp.Compile(tag); err != nil { + return errors.Errorf("tag key %q is not a valid regex pattern: %v", tag, err) + } if metadata == nil { return errors.Errorf("tag metadata is required for %q", tag) } diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx index a7a47c5b9..7b65ba2e3 100644 --- a/web/src/components/MemoContent/Tag.tsx +++ b/web/src/components/MemoContent/Tag.tsx @@ -4,6 +4,7 @@ import { useInstance } from "@/contexts/InstanceContext"; import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import useNavigateTo from "@/hooks/useNavigateTo"; import { colorToHex } from "@/lib/color"; +import { findTagMetadata } from "@/lib/tag"; import { cn } from "@/lib/utils"; import { Routes } from "@/router"; import { useMemoViewContext } from "../MemoView/MemoViewContext"; @@ -26,7 +27,7 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa // Custom color from admin tag metadata. Dynamic hex values must use inline styles // because Tailwind can't scan dynamically constructed class names. // Text uses a darkened variant (40% color + black) for contrast on light backgrounds. - const bgHex = colorToHex(tagsSetting.tags[tag]?.backgroundColor); + const bgHex = colorToHex(findTagMetadata(tag, tagsSetting)?.backgroundColor); const tagStyle: React.CSSProperties | undefined = bgHex ? { borderColor: bgHex, @@ -65,7 +66,7 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa return ( { toast.error(t("setting.tags.tag-already-exists")); return; } + if (!isValidTagPattern(name)) { + toast.error(t("setting.tags.invalid-regex")); + return; + } setLocalTags((prev) => ({ ...prev, [name]: newTagColor })); setNewTagName(""); setNewTagColor("#ffffff"); @@ -136,7 +141,6 @@ const TagsSection = () => { header: t("setting.tags.background-color"), render: (_, row: { name: string }) => (
-
{ value={newTagColor} onChange={(e) => setNewTagColor(e.target.value)} /> -
+

{t("setting.tags.tag-pattern-hint")}

diff --git a/web/src/lib/tag.ts b/web/src/lib/tag.ts new file mode 100644 index 000000000..c51fd9e01 --- /dev/null +++ b/web/src/lib/tag.ts @@ -0,0 +1,64 @@ +import type { InstanceSetting_TagMetadata, InstanceSetting_TagsSetting } from "@/types/proto/api/v1/instance_service_pb"; + +// Cache compiled regexes to avoid re-compiling on every tag render. +const compiledPatternCache = new Map(); + +const getCompiledPattern = (pattern: string): RegExp | null => { + if (compiledPatternCache.has(pattern)) { + return compiledPatternCache.get(pattern)!; + } + let re: RegExp | null = null; + try { + re = new RegExp(`^(?:${pattern})$`); + } catch { + // Invalid pattern — cache as null so we skip it without retrying. + } + compiledPatternCache.set(pattern, re); + return re; +}; + +/** + * Finds the first matching TagMetadata for a given tag name by treating each + * key in tagsSetting.tags as an anchored regex pattern (^pattern$). + * + * Lookup order: + * 1. Exact key match (O(1) fast path, backward-compatible). + * 2. Iterate all keys and test as anchored regex — first match wins. + */ +export const findTagMetadata = (tag: string, tagsSetting: InstanceSetting_TagsSetting): InstanceSetting_TagMetadata | undefined => { + // Fast path: exact match. + if (tagsSetting.tags[tag]) { + return tagsSetting.tags[tag]; + } + + // Regex path: treat each key as an anchored pattern. + for (const [pattern, metadata] of Object.entries(tagsSetting.tags)) { + const re = getCompiledPattern(pattern); + if (re?.test(tag)) { + return metadata; + } + } + + return undefined; +}; + +/** + * Returns true if the given string is a valid, ReDoS-safe JavaScript regex pattern. + * + * Rejects patterns with nested quantifiers (e.g. `(a+)+`) which can cause + * catastrophic backtracking in JavaScript's regex engine. + */ +export const isValidTagPattern = (pattern: string): boolean => { + if (!pattern) return false; + try { + new RegExp(pattern); + } catch { + return false; + } + // Reject nested quantifiers: a quantified group whose body itself contains + // a quantifier — the classic ReDoS shape e.g. (a+)+, (a*b?)+, (x|y+)+. + if (/\((?:[^()]*[*+?{][^()]*)\)[*+?{]/.test(pattern)) { + return false; + } + return true; +}; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f0f6d6dcd..7d5eb6157 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -474,12 +474,14 @@ "tags": { "label": "Tags", "title": "Tag metadata", - "description": "Assign display colors to tags instance-wide.", + "description": "Assign display colors to tags instance-wide. Tag names are treated as anchored regex patterns.", "background-color": "Background color", "no-tags-configured": "No tag metadata configured.", "tag-name": "Tag name", - "tag-name-placeholder": "e.g. work", - "tag-already-exists": "Tag already exists." + "tag-name-placeholder": "e.g. work or project/.*", + "tag-already-exists": "Tag already exists.", + "tag-pattern-hint": "Tag name or regex pattern (e.g. project/.* matches all project/ tags)", + "invalid-regex": "Invalid or unsafe regex pattern." } }, "tag": { diff --git a/web/src/pages/Setting.tsx b/web/src/pages/Setting.tsx index 5cb82e153..7fe176f7b 100644 --- a/web/src/pages/Setting.tsx +++ b/web/src/pages/Setting.tsx @@ -34,7 +34,7 @@ import { useTranslate } from "@/utils/i18n"; type SettingSection = "my-account" | "preference" | "webhook" | "member" | "system" | "memo" | "storage" | "sso" | "tags"; const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference", "webhook"]; -const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo", "storage", "tags", "sso"]; +const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo", "tags", "storage", "sso"]; const SECTION_ICON_MAP: Record = { "my-account": UserIcon,