mirror of https://github.com/usememos/memos.git
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:
parent
9ded59a1aa
commit
9e04049632
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue