feat: treat tag setting keys as anchored regex patterns (#5759)

Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
memoclaw 2026-03-22 08:07:45 +08:00 committed by GitHub
parent 9ded59a1aa
commit 9e04049632
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 92 additions and 8 deletions

View File

@ -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<string, TagMetadata> tags = 1;
}

View File

@ -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<string, InstanceTagMetadata> tags = 1;
}

View File

@ -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)
}

View File

@ -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<TagProps> = ({ "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<TagProps> = ({ "data-tag": dataTag, children, classNa
return (
<span
className={cn(
"inline-flex items-center align-middle px-1.5 py-px text-sm leading-snug rounded border cursor-pointer transition-opacity hover:opacity-75",
"inline-flex items-center px-1 text-sm rounded-full border cursor-pointer transition-opacity hover:opacity-75",
!bgHex && "border-primary text-primary bg-primary/15",
className,
)}

View File

@ -9,6 +9,7 @@ import { useInstance } from "@/contexts/InstanceContext";
import { useTagCounts } from "@/hooks/useUserQueries";
import { colorToHex } from "@/lib/color";
import { handleError } from "@/lib/error";
import { isValidTagPattern } from "@/lib/tag";
import {
InstanceSetting_Key,
InstanceSetting_TagMetadataSchema,
@ -92,6 +93,10 @@ const TagsSection = () => {
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 }) => (
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded border border-border shrink-0" style={{ backgroundColor: localTags[row.name] }} />
<input
type="color"
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5"
@ -184,11 +188,12 @@ const TagsSection = () => {
value={newTagColor}
onChange={(e) => setNewTagColor(e.target.value)}
/>
<Button variant="outline" size="sm" onClick={handleAddTag} disabled={!newTagName.trim()}>
<Button variant="outline" onClick={handleAddTag} disabled={!newTagName.trim()}>
<PlusIcon className="w-4 h-4 mr-1.5" />
{t("common.add")}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">{t("setting.tags.tag-pattern-hint")}</p>
</SettingGroup>
<div className="w-full flex justify-end">

64
web/src/lib/tag.ts Normal file
View File

@ -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<string, RegExp | null>();
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;
};

View File

@ -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": {

View File

@ -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<SettingSection, LucideIcon> = {
"my-account": UserIcon,