Unverified Commit 9e040496 authored by memoclaw's avatar memoclaw Committed by GitHub

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

Co-authored-by: 's avatarmemoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: 's avatarCopilot <223556219+Copilot@users.noreply.github.com>
parent 9ded59a1
...@@ -173,6 +173,10 @@ message InstanceSetting { ...@@ -173,6 +173,10 @@ message InstanceSetting {
// Tag metadata configuration. // Tag metadata configuration.
message TagsSetting { 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; map<string, TagMetadata> tags = 1;
} }
......
...@@ -116,6 +116,10 @@ message InstanceTagMetadata { ...@@ -116,6 +116,10 @@ message InstanceTagMetadata {
} }
message InstanceTagsSetting { 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; map<string, InstanceTagMetadata> tags = 1;
} }
......
...@@ -4,6 +4,7 @@ import ( ...@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"math" "math"
"regexp"
"strings" "strings"
"github.com/pkg/errors" "github.com/pkg/errors"
...@@ -391,6 +392,9 @@ func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) erro ...@@ -391,6 +392,9 @@ func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) erro
if strings.TrimSpace(tag) == "" { if strings.TrimSpace(tag) == "" {
return errors.New("tag key cannot be empty") 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 { if metadata == nil {
return errors.Errorf("tag metadata is required for %q", tag) return errors.Errorf("tag metadata is required for %q", tag)
} }
......
...@@ -4,6 +4,7 @@ import { useInstance } from "@/contexts/InstanceContext"; ...@@ -4,6 +4,7 @@ import { useInstance } from "@/contexts/InstanceContext";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { colorToHex } from "@/lib/color"; import { colorToHex } from "@/lib/color";
import { findTagMetadata } from "@/lib/tag";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { useMemoViewContext } from "../MemoView/MemoViewContext"; import { useMemoViewContext } from "../MemoView/MemoViewContext";
...@@ -26,7 +27,7 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa ...@@ -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 // Custom color from admin tag metadata. Dynamic hex values must use inline styles
// because Tailwind can't scan dynamically constructed class names. // because Tailwind can't scan dynamically constructed class names.
// Text uses a darkened variant (40% color + black) for contrast on light backgrounds. // 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 const tagStyle: React.CSSProperties | undefined = bgHex
? { ? {
borderColor: bgHex, borderColor: bgHex,
...@@ -65,7 +66,7 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa ...@@ -65,7 +66,7 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
return ( return (
<span <span
className={cn( 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", !bgHex && "border-primary text-primary bg-primary/15",
className, className,
)} )}
......
...@@ -9,6 +9,7 @@ import { useInstance } from "@/contexts/InstanceContext"; ...@@ -9,6 +9,7 @@ import { useInstance } from "@/contexts/InstanceContext";
import { useTagCounts } from "@/hooks/useUserQueries"; import { useTagCounts } from "@/hooks/useUserQueries";
import { colorToHex } from "@/lib/color"; import { colorToHex } from "@/lib/color";
import { handleError } from "@/lib/error"; import { handleError } from "@/lib/error";
import { isValidTagPattern } from "@/lib/tag";
import { import {
InstanceSetting_Key, InstanceSetting_Key,
InstanceSetting_TagMetadataSchema, InstanceSetting_TagMetadataSchema,
...@@ -92,6 +93,10 @@ const TagsSection = () => { ...@@ -92,6 +93,10 @@ const TagsSection = () => {
toast.error(t("setting.tags.tag-already-exists")); toast.error(t("setting.tags.tag-already-exists"));
return; return;
} }
if (!isValidTagPattern(name)) {
toast.error(t("setting.tags.invalid-regex"));
return;
}
setLocalTags((prev) => ({ ...prev, [name]: newTagColor })); setLocalTags((prev) => ({ ...prev, [name]: newTagColor }));
setNewTagName(""); setNewTagName("");
setNewTagColor("#ffffff"); setNewTagColor("#ffffff");
...@@ -136,7 +141,6 @@ const TagsSection = () => { ...@@ -136,7 +141,6 @@ const TagsSection = () => {
header: t("setting.tags.background-color"), header: t("setting.tags.background-color"),
render: (_, row: { name: string }) => ( render: (_, row: { name: string }) => (
<div className="flex items-center gap-2"> <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 <input
type="color" type="color"
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5" className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5"
...@@ -184,11 +188,12 @@ const TagsSection = () => { ...@@ -184,11 +188,12 @@ const TagsSection = () => {
value={newTagColor} value={newTagColor}
onChange={(e) => setNewTagColor(e.target.value)} 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" /> <PlusIcon className="w-4 h-4 mr-1.5" />
{t("common.add")} {t("common.add")}
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground mt-1">{t("setting.tags.tag-pattern-hint")}</p>
</SettingGroup> </SettingGroup>
<div className="w-full flex justify-end"> <div className="w-full flex justify-end">
......
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;
};
...@@ -474,12 +474,14 @@ ...@@ -474,12 +474,14 @@
"tags": { "tags": {
"label": "Tags", "label": "Tags",
"title": "Tag metadata", "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", "background-color": "Background color",
"no-tags-configured": "No tag metadata configured.", "no-tags-configured": "No tag metadata configured.",
"tag-name": "Tag name", "tag-name": "Tag name",
"tag-name-placeholder": "e.g. work", "tag-name-placeholder": "e.g. work or project/.*",
"tag-already-exists": "Tag already exists." "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": { "tag": {
......
...@@ -34,7 +34,7 @@ import { useTranslate } from "@/utils/i18n"; ...@@ -34,7 +34,7 @@ import { useTranslate } from "@/utils/i18n";
type SettingSection = "my-account" | "preference" | "webhook" | "member" | "system" | "memo" | "storage" | "sso" | "tags"; type SettingSection = "my-account" | "preference" | "webhook" | "member" | "system" | "memo" | "storage" | "sso" | "tags";
const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference", "webhook"]; 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> = { const SECTION_ICON_MAP: Record<SettingSection, LucideIcon> = {
"my-account": UserIcon, "my-account": UserIcon,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment