Unverified Commit 65d14fbb authored by boojack's avatar boojack Committed by GitHub

feat(instance): add canonical tag metadata setting (#5736)

parent 330291d4
...@@ -8,6 +8,7 @@ import "google/api/client.proto"; ...@@ -8,6 +8,7 @@ import "google/api/client.proto";
import "google/api/field_behavior.proto"; import "google/api/field_behavior.proto";
import "google/api/resource.proto"; import "google/api/resource.proto";
import "google/protobuf/field_mask.proto"; import "google/protobuf/field_mask.proto";
import "google/type/color.proto";
option go_package = "gen/api/v1"; option go_package = "gen/api/v1";
...@@ -69,6 +70,7 @@ message InstanceSetting { ...@@ -69,6 +70,7 @@ message InstanceSetting {
GeneralSetting general_setting = 2; GeneralSetting general_setting = 2;
StorageSetting storage_setting = 3; StorageSetting storage_setting = 3;
MemoRelatedSetting memo_related_setting = 4; MemoRelatedSetting memo_related_setting = 4;
TagsSetting tags_setting = 5;
} }
// Enumeration of instance setting keys. // Enumeration of instance setting keys.
...@@ -80,6 +82,8 @@ message InstanceSetting { ...@@ -80,6 +82,8 @@ message InstanceSetting {
STORAGE = 2; STORAGE = 2;
// MEMO_RELATED is the key for memo related settings. // MEMO_RELATED is the key for memo related settings.
MEMO_RELATED = 3; MEMO_RELATED = 3;
// TAGS is the key for tag metadata.
TAGS = 4;
} }
// General instance settings configuration. // General instance settings configuration.
...@@ -159,6 +163,17 @@ message InstanceSetting { ...@@ -159,6 +163,17 @@ message InstanceSetting {
// reactions is the list of reactions. // reactions is the list of reactions.
repeated string reactions = 7; repeated string reactions = 7;
} }
// Metadata for a tag.
message TagMetadata {
// Background color for the tag label.
google.type.Color background_color = 1;
}
// Tag metadata configuration.
message TagsSetting {
map<string, TagMetadata> tags = 1;
}
} }
// Request message for GetInstanceSetting method. // Request message for GetInstanceSetting method.
......
...@@ -10,7 +10,6 @@ import "google/api/resource.proto"; ...@@ -10,7 +10,6 @@ import "google/api/resource.proto";
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto"; import "google/protobuf/field_mask.proto";
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
import "google/type/color.proto";
option go_package = "gen/api/v1"; option go_package = "gen/api/v1";
...@@ -376,7 +375,6 @@ message UserSetting { ...@@ -376,7 +375,6 @@ message UserSetting {
oneof value { oneof value {
GeneralSetting general_setting = 2; GeneralSetting general_setting = 2;
WebhooksSetting webhooks_setting = 5; WebhooksSetting webhooks_setting = 5;
TagsSetting tags_setting = 6;
} }
// Enumeration of user setting keys. // Enumeration of user setting keys.
...@@ -386,8 +384,6 @@ message UserSetting { ...@@ -386,8 +384,6 @@ message UserSetting {
GENERAL = 1; GENERAL = 1;
// WEBHOOKS is the key for user webhooks. // WEBHOOKS is the key for user webhooks.
WEBHOOKS = 4; WEBHOOKS = 4;
// TAGS is the key for user tag metadata.
TAGS = 5;
} }
// General user settings configuration. // General user settings configuration.
...@@ -407,17 +403,6 @@ message UserSetting { ...@@ -407,17 +403,6 @@ message UserSetting {
// List of user webhooks. // List of user webhooks.
repeated UserWebhook webhooks = 1; repeated UserWebhook webhooks = 1;
} }
// Metadata for a tag.
message TagMetadata {
// Background color for the tag label.
google.type.Color background_color = 1;
}
// User tag metadata configuration.
message TagsSetting {
map<string, TagMetadata> tags = 1;
}
} }
message GetUserSettingRequest { message GetUserSettingRequest {
......
This diff is collapsed.
This diff is collapsed.
...@@ -2190,6 +2190,8 @@ components: ...@@ -2190,6 +2190,8 @@ components:
$ref: '#/components/schemas/InstanceSetting_StorageSetting' $ref: '#/components/schemas/InstanceSetting_StorageSetting'
memoRelatedSetting: memoRelatedSetting:
$ref: '#/components/schemas/InstanceSetting_MemoRelatedSetting' $ref: '#/components/schemas/InstanceSetting_MemoRelatedSetting'
tagsSetting:
$ref: '#/components/schemas/InstanceSetting_TagsSetting'
description: An instance setting resource. description: An instance setting resource.
InstanceSetting_GeneralSetting: InstanceSetting_GeneralSetting:
type: object type: object
...@@ -2271,6 +2273,22 @@ components: ...@@ -2271,6 +2273,22 @@ components:
- $ref: '#/components/schemas/StorageSetting_S3Config' - $ref: '#/components/schemas/StorageSetting_S3Config'
description: The S3 config. description: The S3 config.
description: Storage configuration settings for instance attachments. description: Storage configuration settings for instance attachments.
InstanceSetting_TagMetadata:
type: object
properties:
backgroundColor:
allOf:
- $ref: '#/components/schemas/Color'
description: Background color for the tag label.
description: Metadata for a tag.
InstanceSetting_TagsSetting:
type: object
properties:
tags:
type: object
additionalProperties:
$ref: '#/components/schemas/InstanceSetting_TagMetadata'
description: Tag metadata configuration.
ListAllUserStatsResponse: ListAllUserStatsResponse:
type: object type: object
properties: properties:
...@@ -2986,8 +3004,6 @@ components: ...@@ -2986,8 +3004,6 @@ components:
$ref: '#/components/schemas/UserSetting_GeneralSetting' $ref: '#/components/schemas/UserSetting_GeneralSetting'
webhooksSetting: webhooksSetting:
$ref: '#/components/schemas/UserSetting_WebhooksSetting' $ref: '#/components/schemas/UserSetting_WebhooksSetting'
tagsSetting:
$ref: '#/components/schemas/UserSetting_TagsSetting'
description: User settings message description: User settings message
UserSetting_GeneralSetting: UserSetting_GeneralSetting:
type: object type: object
...@@ -3005,22 +3021,6 @@ components: ...@@ -3005,22 +3021,6 @@ components:
This references a CSS file in the web/public/themes/ directory. This references a CSS file in the web/public/themes/ directory.
If not set, the default theme will be used. If not set, the default theme will be used.
description: General user settings configuration. description: General user settings configuration.
UserSetting_TagMetadata:
type: object
properties:
backgroundColor:
allOf:
- $ref: '#/components/schemas/Color'
description: Background color for the tag label.
description: Metadata for a tag.
UserSetting_TagsSetting:
type: object
properties:
tags:
type: object
additionalProperties:
$ref: '#/components/schemas/UserSetting_TagMetadata'
description: User tag metadata configuration.
UserSetting_WebhooksSetting: UserSetting_WebhooksSetting:
type: object type: object
properties: properties:
......
This diff is collapsed.
This diff is collapsed.
...@@ -2,6 +2,8 @@ syntax = "proto3"; ...@@ -2,6 +2,8 @@ syntax = "proto3";
package memos.store; package memos.store;
import "google/type/color.proto";
option go_package = "gen/store"; option go_package = "gen/store";
enum InstanceSettingKey { enum InstanceSettingKey {
...@@ -14,6 +16,8 @@ enum InstanceSettingKey { ...@@ -14,6 +16,8 @@ enum InstanceSettingKey {
STORAGE = 3; STORAGE = 3;
// MEMO_RELATED is the key for memo related settings. // MEMO_RELATED is the key for memo related settings.
MEMO_RELATED = 4; MEMO_RELATED = 4;
// TAGS is the key for tag metadata.
TAGS = 5;
} }
message InstanceSetting { message InstanceSetting {
...@@ -23,6 +27,7 @@ message InstanceSetting { ...@@ -23,6 +27,7 @@ message InstanceSetting {
InstanceGeneralSetting general_setting = 3; InstanceGeneralSetting general_setting = 3;
InstanceStorageSetting storage_setting = 4; InstanceStorageSetting storage_setting = 4;
InstanceMemoRelatedSetting memo_related_setting = 5; InstanceMemoRelatedSetting memo_related_setting = 5;
InstanceTagsSetting tags_setting = 6;
} }
} }
...@@ -103,3 +108,12 @@ message InstanceMemoRelatedSetting { ...@@ -103,3 +108,12 @@ message InstanceMemoRelatedSetting {
// reactions is the list of reactions. // reactions is the list of reactions.
repeated string reactions = 7; repeated string reactions = 7;
} }
message InstanceTagMetadata {
// Background color for the tag label.
google.type.Color background_color = 1;
}
message InstanceTagsSetting {
map<string, InstanceTagMetadata> tags = 1;
}
...@@ -3,7 +3,6 @@ syntax = "proto3"; ...@@ -3,7 +3,6 @@ syntax = "proto3";
package memos.store; package memos.store;
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
import "google/type/color.proto";
option go_package = "gen/store"; option go_package = "gen/store";
...@@ -20,8 +19,6 @@ message UserSetting { ...@@ -20,8 +19,6 @@ message UserSetting {
REFRESH_TOKENS = 6; REFRESH_TOKENS = 6;
// Personal access tokens for the user. // Personal access tokens for the user.
PERSONAL_ACCESS_TOKENS = 7; PERSONAL_ACCESS_TOKENS = 7;
// Tag metadata for the user.
TAGS = 8;
} }
int32 user_id = 1; int32 user_id = 1;
...@@ -33,7 +30,6 @@ message UserSetting { ...@@ -33,7 +30,6 @@ message UserSetting {
WebhooksUserSetting webhooks = 7; WebhooksUserSetting webhooks = 7;
RefreshTokensUserSetting refresh_tokens = 8; RefreshTokensUserSetting refresh_tokens = 8;
PersonalAccessTokensUserSetting personal_access_tokens = 9; PersonalAccessTokensUserSetting personal_access_tokens = 9;
TagsUserSetting tags = 10;
} }
} }
...@@ -115,12 +111,3 @@ message WebhooksUserSetting { ...@@ -115,12 +111,3 @@ message WebhooksUserSetting {
} }
repeated Webhook webhooks = 1; repeated Webhook webhooks = 1;
} }
message TagMetadata {
// Background color for the tag label.
google.type.Color background_color = 1;
}
message TagsUserSetting {
map<string, TagMetadata> tags = 1;
}
...@@ -3,8 +3,11 @@ package v1 ...@@ -3,8 +3,11 @@ package v1
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"strings"
"github.com/pkg/errors" "github.com/pkg/errors"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
...@@ -46,6 +49,8 @@ func (s *APIV1Service) GetInstanceSetting(ctx context.Context, request *v1pb.Get ...@@ -46,6 +49,8 @@ func (s *APIV1Service) GetInstanceSetting(ctx context.Context, request *v1pb.Get
_, err = s.Store.GetInstanceMemoRelatedSetting(ctx) _, err = s.Store.GetInstanceMemoRelatedSetting(ctx)
case storepb.InstanceSettingKey_STORAGE: case storepb.InstanceSettingKey_STORAGE:
_, err = s.Store.GetInstanceStorageSetting(ctx) _, err = s.Store.GetInstanceStorageSetting(ctx)
case storepb.InstanceSettingKey_TAGS:
_, err = s.Store.GetInstanceTagsSetting(ctx)
default: default:
return nil, status.Errorf(codes.InvalidArgument, "unsupported instance setting key: %v", instanceSettingKey) return nil, status.Errorf(codes.InvalidArgument, "unsupported instance setting key: %v", instanceSettingKey)
} }
...@@ -95,6 +100,10 @@ func (s *APIV1Service) UpdateInstanceSetting(ctx context.Context, request *v1pb. ...@@ -95,6 +100,10 @@ func (s *APIV1Service) UpdateInstanceSetting(ctx context.Context, request *v1pb.
// TODO: Apply update_mask if specified // TODO: Apply update_mask if specified
_ = request.UpdateMask _ = request.UpdateMask
if err := validateInstanceSetting(request.Setting); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid instance setting: %v", err)
}
updateSetting := convertInstanceSettingToStore(request.Setting) updateSetting := convertInstanceSettingToStore(request.Setting)
instanceSetting, err := s.Store.UpsertInstanceSetting(ctx, updateSetting) instanceSetting, err := s.Store.UpsertInstanceSetting(ctx, updateSetting)
if err != nil { if err != nil {
...@@ -121,6 +130,10 @@ func convertInstanceSettingFromStore(setting *storepb.InstanceSetting) *v1pb.Ins ...@@ -121,6 +130,10 @@ func convertInstanceSettingFromStore(setting *storepb.InstanceSetting) *v1pb.Ins
instanceSetting.Value = &v1pb.InstanceSetting_MemoRelatedSetting_{ instanceSetting.Value = &v1pb.InstanceSetting_MemoRelatedSetting_{
MemoRelatedSetting: convertInstanceMemoRelatedSettingFromStore(setting.GetMemoRelatedSetting()), MemoRelatedSetting: convertInstanceMemoRelatedSettingFromStore(setting.GetMemoRelatedSetting()),
} }
case *storepb.InstanceSetting_TagsSetting:
instanceSetting.Value = &v1pb.InstanceSetting_TagsSetting_{
TagsSetting: convertInstanceTagsSettingFromStore(setting.GetTagsSetting()),
}
default: default:
// Leave Value unset for unsupported setting variants. // Leave Value unset for unsupported setting variants.
} }
...@@ -148,6 +161,10 @@ func convertInstanceSettingToStore(setting *v1pb.InstanceSetting) *storepb.Insta ...@@ -148,6 +161,10 @@ func convertInstanceSettingToStore(setting *v1pb.InstanceSetting) *storepb.Insta
instanceSetting.Value = &storepb.InstanceSetting_MemoRelatedSetting{ instanceSetting.Value = &storepb.InstanceSetting_MemoRelatedSetting{
MemoRelatedSetting: convertInstanceMemoRelatedSettingToStore(setting.GetMemoRelatedSetting()), MemoRelatedSetting: convertInstanceMemoRelatedSettingToStore(setting.GetMemoRelatedSetting()),
} }
case storepb.InstanceSettingKey_TAGS:
instanceSetting.Value = &storepb.InstanceSetting_TagsSetting{
TagsSetting: convertInstanceTagsSettingToStore(setting.GetTagsSetting()),
}
default: default:
// Keep the default GeneralSetting value // Keep the default GeneralSetting value
} }
...@@ -271,6 +288,96 @@ func convertInstanceMemoRelatedSettingToStore(setting *v1pb.InstanceSetting_Memo ...@@ -271,6 +288,96 @@ func convertInstanceMemoRelatedSettingToStore(setting *v1pb.InstanceSetting_Memo
} }
} }
func convertInstanceTagsSettingFromStore(setting *storepb.InstanceTagsSetting) *v1pb.InstanceSetting_TagsSetting {
if setting == nil {
return nil
}
tags := make(map[string]*v1pb.InstanceSetting_TagMetadata, len(setting.Tags))
for tag, metadata := range setting.Tags {
tags[tag] = &v1pb.InstanceSetting_TagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
}
}
return &v1pb.InstanceSetting_TagsSetting{
Tags: tags,
}
}
func convertInstanceTagsSettingToStore(setting *v1pb.InstanceSetting_TagsSetting) *storepb.InstanceTagsSetting {
if setting == nil {
return nil
}
tags := make(map[string]*storepb.InstanceTagMetadata, len(setting.Tags))
for tag, metadata := range setting.Tags {
tags[tag] = &storepb.InstanceTagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
}
}
return &storepb.InstanceTagsSetting{
Tags: tags,
}
}
func validateInstanceSetting(setting *v1pb.InstanceSetting) error {
key, err := ExtractInstanceSettingKeyFromName(setting.Name)
if err != nil {
return err
}
if key != storepb.InstanceSettingKey_TAGS.String() {
return nil
}
return validateInstanceTagsSetting(setting.GetTagsSetting())
}
func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) error {
if setting == nil {
return errors.New("tags setting is required")
}
for tag, metadata := range setting.Tags {
if strings.TrimSpace(tag) == "" {
return errors.New("tag key cannot be empty")
}
if metadata == nil {
return errors.Errorf("tag metadata is required for %q", tag)
}
if metadata.GetBackgroundColor() == nil {
return errors.Errorf("background_color is required for %q", tag)
}
if err := validateInstanceColor(metadata.GetBackgroundColor()); err != nil {
return errors.Wrapf(err, "background_color for %q", tag)
}
}
return nil
}
func validateInstanceColor(color *colorpb.Color) error {
if err := validateInstanceColorComponent("red", color.GetRed()); err != nil {
return err
}
if err := validateInstanceColorComponent("green", color.GetGreen()); err != nil {
return err
}
if err := validateInstanceColorComponent("blue", color.GetBlue()); err != nil {
return err
}
if alpha := color.GetAlpha(); alpha != nil {
if err := validateInstanceColorComponent("alpha", alpha.GetValue()); err != nil {
return err
}
}
return nil
}
func validateInstanceColorComponent(name string, value float32) error {
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return errors.Errorf("%s must be a finite number", name)
}
if value < 0 || value > 1 {
return errors.Errorf("%s must be between 0 and 1", name)
}
return nil
}
func (s *APIV1Service) GetInstanceAdmin(ctx context.Context) (*v1pb.User, error) { func (s *APIV1Service) GetInstanceAdmin(ctx context.Context) (*v1pb.User, error) {
adminUserType := store.RoleAdmin adminUserType := store.RoleAdmin
user, err := s.Store.GetUser(ctx, &store.FindUser{ user, err := s.Store.GetUser(ctx, &store.FindUser{
......
...@@ -5,6 +5,8 @@ import ( ...@@ -5,6 +5,8 @@ import (
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/protobuf/types/known/fieldmaskpb"
v1pb "github.com/usememos/memos/proto/gen/api/v1" v1pb "github.com/usememos/memos/proto/gen/api/v1"
) )
...@@ -186,6 +188,22 @@ func TestGetInstanceSetting(t *testing.T) { ...@@ -186,6 +188,22 @@ func TestGetInstanceSetting(t *testing.T) {
require.NotNil(t, memoRelatedSetting) require.NotNil(t, memoRelatedSetting)
}) })
t.Run("GetInstanceSetting - tags setting", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
req := &v1pb.GetInstanceSettingRequest{
Name: "instance/settings/TAGS",
}
resp, err := ts.Service.GetInstanceSetting(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, "instance/settings/TAGS", resp.Name)
require.NotNil(t, resp.GetTagsSetting())
require.Empty(t, resp.GetTagsSetting().GetTags())
})
t.Run("GetInstanceSetting - invalid setting name", func(t *testing.T) { t.Run("GetInstanceSetting - invalid setting name", func(t *testing.T) {
// Create test service for this specific test // Create test service for this specific test
ts := NewTestService(t) ts := NewTestService(t)
...@@ -202,3 +220,67 @@ func TestGetInstanceSetting(t *testing.T) { ...@@ -202,3 +220,67 @@ func TestGetInstanceSetting(t *testing.T) {
require.Contains(t, err.Error(), "invalid instance setting name") require.Contains(t, err.Error(), "invalid instance setting name")
}) })
} }
func TestUpdateInstanceSetting(t *testing.T) {
ctx := context.Background()
t.Run("UpdateInstanceSetting - tags setting", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
hostUser, err := ts.CreateHostUser(ctx, "admin")
require.NoError(t, err)
resp, err := ts.Service.UpdateInstanceSetting(ts.CreateUserContext(ctx, hostUser.ID), &v1pb.UpdateInstanceSettingRequest{
Setting: &v1pb.InstanceSetting{
Name: "instance/settings/TAGS",
Value: &v1pb.InstanceSetting_TagsSetting_{
TagsSetting: &v1pb.InstanceSetting_TagsSetting{
Tags: map[string]*v1pb.InstanceSetting_TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 0.9,
Green: 0.1,
Blue: 0.1,
},
},
},
},
},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"tags"}},
})
require.NoError(t, err)
require.NotNil(t, resp.GetTagsSetting())
require.Contains(t, resp.GetTagsSetting().GetTags(), "bug")
})
t.Run("UpdateInstanceSetting - invalid tags color", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
hostUser, err := ts.CreateHostUser(ctx, "admin")
require.NoError(t, err)
_, err = ts.Service.UpdateInstanceSetting(ts.CreateUserContext(ctx, hostUser.ID), &v1pb.UpdateInstanceSettingRequest{
Setting: &v1pb.InstanceSetting{
Name: "instance/settings/TAGS",
Value: &v1pb.InstanceSetting_TagsSetting_{
TagsSetting: &v1pb.InstanceSetting_TagsSetting{
Tags: map[string]*v1pb.InstanceSetting_TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 1.2,
Green: 0.1,
Blue: 0.1,
},
},
},
},
},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid instance setting")
})
}
package test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/protobuf/types/known/fieldmaskpb"
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
)
func TestUserSettingTags(t *testing.T) {
t.Parallel()
ctx := context.Background()
t.Run("GetUserSetting returns empty tags setting by default", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateHostUser(ctx, "tags-default")
require.NoError(t, err)
response, err := ts.Service.GetUserSetting(ts.CreateUserContext(ctx, user.ID), &apiv1.GetUserSettingRequest{
Name: fmt.Sprintf("users/%d/settings/TAGS", user.ID),
})
require.NoError(t, err)
require.NotNil(t, response)
require.NotNil(t, response.GetTagsSetting())
require.Empty(t, response.GetTagsSetting().GetTags())
})
t.Run("UpdateUserSetting replaces tag metadata", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateHostUser(ctx, "tags-update")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
settingName := fmt.Sprintf("users/%d/settings/TAGS", user.ID)
updateRequest := &apiv1.UpdateUserSettingRequest{
Setting: &apiv1.UserSetting{
Name: settingName,
Value: &apiv1.UserSetting_TagsSetting_{
TagsSetting: &apiv1.UserSetting_TagsSetting{
Tags: map[string]*apiv1.UserSetting_TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 0.9,
Green: 0.1,
Blue: 0.1,
},
},
},
},
},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"tags"}},
}
response, err := ts.Service.UpdateUserSetting(userCtx, updateRequest)
require.NoError(t, err)
require.NotNil(t, response.GetTagsSetting())
require.Contains(t, response.GetTagsSetting().GetTags(), "bug")
require.InDelta(t, 0.9, response.GetTagsSetting().GetTags()["bug"].GetBackgroundColor().GetRed(), 0.0001)
getResponse, err := ts.Service.GetUserSetting(userCtx, &apiv1.GetUserSettingRequest{Name: settingName})
require.NoError(t, err)
require.Len(t, getResponse.GetTagsSetting().GetTags(), 1)
require.Contains(t, getResponse.GetTagsSetting().GetTags(), "bug")
})
t.Run("UpdateUserSetting rejects invalid color", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateHostUser(ctx, "tags-invalid")
require.NoError(t, err)
_, err = ts.Service.UpdateUserSetting(ts.CreateUserContext(ctx, user.ID), &apiv1.UpdateUserSettingRequest{
Setting: &apiv1.UserSetting{
Name: fmt.Sprintf("users/%d/settings/TAGS", user.ID),
Value: &apiv1.UserSetting_TagsSetting_{
TagsSetting: &apiv1.UserSetting_TagsSetting{
Tags: map[string]*apiv1.UserSetting_TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 1.2,
Green: 0.1,
Blue: 0.1,
},
},
},
},
},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"tags"}},
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid tags setting")
})
t.Run("Other users cannot read or update tag metadata", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateHostUser(ctx, "tags-owner")
require.NoError(t, err)
otherUser, err := ts.CreateHostUser(ctx, "tags-other")
require.NoError(t, err)
settingName := fmt.Sprintf("users/%d/settings/TAGS", user.ID)
_, err = ts.Service.GetUserSetting(ts.CreateUserContext(ctx, otherUser.ID), &apiv1.GetUserSettingRequest{
Name: settingName,
})
require.Error(t, err)
require.Contains(t, err.Error(), "permission denied")
_, err = ts.Service.UpdateUserSetting(ts.CreateUserContext(ctx, otherUser.ID), &apiv1.UpdateUserSettingRequest{
Setting: &apiv1.UserSetting{
Name: settingName,
Value: &apiv1.UserSetting_TagsSetting_{
TagsSetting: &apiv1.UserSetting_TagsSetting{
Tags: map[string]*apiv1.UserSetting_TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 0.1,
Green: 0.2,
Blue: 0.3,
},
},
},
},
},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"tags"}},
})
require.Error(t, err)
require.Contains(t, err.Error(), "permission denied")
})
}
...@@ -5,7 +5,6 @@ import ( ...@@ -5,7 +5,6 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"math"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
...@@ -15,7 +14,6 @@ import ( ...@@ -15,7 +14,6 @@ import (
"github.com/google/cel-go/common/ast" "github.com/google/cel-go/common/ast"
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
...@@ -334,12 +332,6 @@ func getDefaultUserGeneralSetting() *v1pb.UserSetting_GeneralSetting { ...@@ -334,12 +332,6 @@ func getDefaultUserGeneralSetting() *v1pb.UserSetting_GeneralSetting {
} }
} }
func getDefaultUserTagsSetting() *v1pb.UserSetting_TagsSetting {
return &v1pb.UserSetting_TagsSetting{
Tags: map[string]*v1pb.UserSetting_TagMetadata{},
}
}
func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUserSettingRequest) (*v1pb.UserSetting, error) { func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUserSettingRequest) (*v1pb.UserSetting, error) {
// Parse resource name: users/{user}/settings/{setting} // Parse resource name: users/{user}/settings/{setting}
userID, settingKey, err := ExtractUserIDAndSettingKeyFromName(request.Name) userID, settingKey, err := ExtractUserIDAndSettingKeyFromName(request.Name)
...@@ -450,24 +442,6 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda ...@@ -450,24 +442,6 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
GeneralSetting: updatedGeneral, GeneralSetting: updatedGeneral,
}, },
} }
case storepb.UserSetting_TAGS:
if len(request.UpdateMask.Paths) != 1 || request.UpdateMask.Paths[0] != "tags" {
return nil, status.Errorf(codes.InvalidArgument, "tags setting only supports update_mask [\"tags\"]")
}
incomingTags := request.Setting.GetTagsSetting()
if incomingTags == nil {
return nil, status.Errorf(codes.InvalidArgument, "tags setting is required")
}
normalizedTags, err := validateAndNormalizeUserTagsSetting(incomingTags)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid tags setting: %v", err)
}
updatedSetting = &v1pb.UserSetting{
Name: request.Setting.Name,
Value: &v1pb.UserSetting_TagsSetting_{
TagsSetting: normalizedTags,
},
}
default: default:
return nil, status.Errorf(codes.InvalidArgument, "setting type %s should not be updated via UpdateUserSetting", storeKey.String()) return nil, status.Errorf(codes.InvalidArgument, "setting type %s should not be updated via UpdateUserSetting", storeKey.String())
} }
...@@ -521,14 +495,10 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU ...@@ -521,14 +495,10 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU
} }
hasGeneral := false hasGeneral := false
hasTags := false
for _, setting := range settings { for _, setting := range settings {
if setting.GetGeneralSetting() != nil { if setting.GetGeneralSetting() != nil {
hasGeneral = true hasGeneral = true
} }
if setting.GetTagsSetting() != nil {
hasTags = true
}
} }
if !hasGeneral { if !hasGeneral {
defaultGeneral := &v1pb.UserSetting{ defaultGeneral := &v1pb.UserSetting{
...@@ -539,16 +509,6 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU ...@@ -539,16 +509,6 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU
} }
settings = append([]*v1pb.UserSetting{defaultGeneral}, settings...) settings = append([]*v1pb.UserSetting{defaultGeneral}, settings...)
} }
if !hasTags {
defaultTags := &v1pb.UserSetting{
Name: fmt.Sprintf("users/%d/settings/%s", userID, convertSettingKeyFromStore(storepb.UserSetting_TAGS)),
Value: &v1pb.UserSetting_TagsSetting_{
TagsSetting: getDefaultUserTagsSetting(),
},
}
settings = append(settings, defaultTags)
}
response := &v1pb.ListUserSettingsResponse{ response := &v1pb.ListUserSettingsResponse{
Settings: settings, Settings: settings,
TotalSize: int32(len(settings)), TotalSize: int32(len(settings)),
...@@ -1037,8 +997,6 @@ func convertSettingKeyToStore(key string) (storepb.UserSetting_Key, error) { ...@@ -1037,8 +997,6 @@ func convertSettingKeyToStore(key string) (storepb.UserSetting_Key, error) {
return storepb.UserSetting_GENERAL, nil return storepb.UserSetting_GENERAL, nil
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_WEBHOOKS)]: case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_WEBHOOKS)]:
return storepb.UserSetting_WEBHOOKS, nil return storepb.UserSetting_WEBHOOKS, nil
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_TAGS)]:
return storepb.UserSetting_TAGS, nil
default: default:
return storepb.UserSetting_KEY_UNSPECIFIED, errors.Errorf("unknown setting key: %s", key) return storepb.UserSetting_KEY_UNSPECIFIED, errors.Errorf("unknown setting key: %s", key)
} }
...@@ -1053,8 +1011,6 @@ func convertSettingKeyFromStore(key storepb.UserSetting_Key) string { ...@@ -1053,8 +1011,6 @@ func convertSettingKeyFromStore(key storepb.UserSetting_Key) string {
return "SHORTCUTS" // Not defined in API proto return "SHORTCUTS" // Not defined in API proto
case storepb.UserSetting_WEBHOOKS: case storepb.UserSetting_WEBHOOKS:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_WEBHOOKS)] return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_WEBHOOKS)]
case storepb.UserSetting_TAGS:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_TAGS)]
default: default:
return "unknown" return "unknown"
} }
...@@ -1076,10 +1032,6 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32 ...@@ -1076,10 +1032,6 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32
Webhooks: []*v1pb.UserWebhook{}, Webhooks: []*v1pb.UserWebhook{},
}, },
} }
case storepb.UserSetting_TAGS:
setting.Value = &v1pb.UserSetting_TagsSetting_{
TagsSetting: getDefaultUserTagsSetting(),
}
default: default:
// Default to general setting // Default to general setting
setting.Value = &v1pb.UserSetting_GeneralSetting_{ setting.Value = &v1pb.UserSetting_GeneralSetting_{
...@@ -1125,19 +1077,6 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32 ...@@ -1125,19 +1077,6 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32
Webhooks: apiWebhooks, Webhooks: apiWebhooks,
}, },
} }
case storepb.UserSetting_TAGS:
tags := storeSetting.GetTags()
apiTags := make(map[string]*v1pb.UserSetting_TagMetadata, len(tags.GetTags()))
for tag, metadata := range tags.GetTags() {
apiTags[tag] = &v1pb.UserSetting_TagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
}
}
setting.Value = &v1pb.UserSetting_TagsSetting_{
TagsSetting: &v1pb.UserSetting_TagsSetting{
Tags: apiTags,
},
}
default: default:
// Default to general setting if unknown key // Default to general setting if unknown key
setting.Value = &v1pb.UserSetting_GeneralSetting_{ setting.Value = &v1pb.UserSetting_GeneralSetting_{
...@@ -1187,22 +1126,6 @@ func convertUserSettingToStore(apiSetting *v1pb.UserSetting, userID int32, key s ...@@ -1187,22 +1126,6 @@ func convertUserSettingToStore(apiSetting *v1pb.UserSetting, userID int32, key s
} else { } else {
return nil, errors.Errorf("webhooks setting is required") return nil, errors.Errorf("webhooks setting is required")
} }
case storepb.UserSetting_TAGS:
if tags := apiSetting.GetTagsSetting(); tags != nil {
storeTags := make(map[string]*storepb.TagMetadata, len(tags.GetTags()))
for tag, metadata := range tags.GetTags() {
storeTags[tag] = &storepb.TagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
}
}
storeSetting.Value = &storepb.UserSetting_Tags{
Tags: &storepb.TagsUserSetting{
Tags: storeTags,
},
}
} else {
return nil, errors.Errorf("tags setting is required")
}
default: default:
return nil, errors.Errorf("unsupported setting key: %v", key) return nil, errors.Errorf("unsupported setting key: %v", key)
} }
...@@ -1220,59 +1143,6 @@ func extractWebhookIDFromName(name string) string { ...@@ -1220,59 +1143,6 @@ func extractWebhookIDFromName(name string) string {
return "" return ""
} }
func validateAndNormalizeUserTagsSetting(tagsSetting *v1pb.UserSetting_TagsSetting) (*v1pb.UserSetting_TagsSetting, error) {
normalized := &v1pb.UserSetting_TagsSetting{
Tags: make(map[string]*v1pb.UserSetting_TagMetadata, len(tagsSetting.GetTags())),
}
for tag, metadata := range tagsSetting.GetTags() {
if strings.TrimSpace(tag) == "" {
return nil, errors.New("tag key cannot be empty")
}
if metadata == nil {
return nil, errors.Errorf("tag metadata is required for %q", tag)
}
backgroundColor := metadata.GetBackgroundColor()
if backgroundColor == nil {
return nil, errors.Errorf("background_color is required for %q", tag)
}
if err := validateColor(backgroundColor); err != nil {
return nil, errors.Wrapf(err, "background_color for %q", tag)
}
normalized.Tags[tag] = &v1pb.UserSetting_TagMetadata{
BackgroundColor: backgroundColor,
}
}
return normalized, nil
}
func validateColor(color *colorpb.Color) error {
if err := validateColorComponent("red", color.GetRed()); err != nil {
return err
}
if err := validateColorComponent("green", color.GetGreen()); err != nil {
return err
}
if err := validateColorComponent("blue", color.GetBlue()); err != nil {
return err
}
if alpha := color.GetAlpha(); alpha != nil {
if err := validateColorComponent("alpha", alpha.GetValue()); err != nil {
return err
}
}
return nil
}
func validateColorComponent(name string, value float32) error {
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return errors.Errorf("%s must be a finite number", name)
}
if value < 0 || value > 1 {
return errors.Errorf("%s must be between 0 and 1", name)
}
return nil
}
// extractUsernameFromFilter extracts username from the filter string using CEL. // extractUsernameFromFilter extracts username from the filter string using CEL.
// Supported filter format: "username == 'steven'" // Supported filter format: "username == 'steven'"
// Returns the username value and an error if the filter format is invalid. // Returns the username value and an error if the filter format is invalid.
......
...@@ -37,6 +37,8 @@ func (s *Store) UpsertInstanceSetting(ctx context.Context, upsert *storepb.Insta ...@@ -37,6 +37,8 @@ func (s *Store) UpsertInstanceSetting(ctx context.Context, upsert *storepb.Insta
valueBytes, err = protojson.Marshal(upsert.GetStorageSetting()) valueBytes, err = protojson.Marshal(upsert.GetStorageSetting())
} else if upsert.Key == storepb.InstanceSettingKey_MEMO_RELATED { } else if upsert.Key == storepb.InstanceSettingKey_MEMO_RELATED {
valueBytes, err = protojson.Marshal(upsert.GetMemoRelatedSetting()) valueBytes, err = protojson.Marshal(upsert.GetMemoRelatedSetting())
} else if upsert.Key == storepb.InstanceSettingKey_TAGS {
valueBytes, err = protojson.Marshal(upsert.GetTagsSetting())
} else { } else {
return nil, errors.Errorf("unsupported instance setting key: %v", upsert.Key) return nil, errors.Errorf("unsupported instance setting key: %v", upsert.Key)
} }
...@@ -168,6 +170,28 @@ func (s *Store) GetInstanceMemoRelatedSetting(ctx context.Context) (*storepb.Ins ...@@ -168,6 +170,28 @@ func (s *Store) GetInstanceMemoRelatedSetting(ctx context.Context) (*storepb.Ins
return instanceMemoRelatedSetting, nil return instanceMemoRelatedSetting, nil
} }
func (s *Store) GetInstanceTagsSetting(ctx context.Context) (*storepb.InstanceTagsSetting, error) {
instanceSetting, err := s.GetInstanceSetting(ctx, &FindInstanceSetting{
Name: storepb.InstanceSettingKey_TAGS.String(),
})
if err != nil {
return nil, errors.Wrap(err, "failed to get instance tags setting")
}
instanceTagsSetting := &storepb.InstanceTagsSetting{}
if instanceSetting != nil {
instanceTagsSetting = instanceSetting.GetTagsSetting()
}
if instanceTagsSetting.Tags == nil {
instanceTagsSetting.Tags = map[string]*storepb.InstanceTagMetadata{}
}
s.instanceSettingCache.Set(ctx, storepb.InstanceSettingKey_TAGS.String(), &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_TAGS,
Value: &storepb.InstanceSetting_TagsSetting{TagsSetting: instanceTagsSetting},
})
return instanceTagsSetting, nil
}
const ( const (
defaultInstanceStorageType = storepb.InstanceStorageSetting_LOCAL defaultInstanceStorageType = storepb.InstanceStorageSetting_LOCAL
defaultInstanceUploadSizeLimitMb = 30 defaultInstanceUploadSizeLimitMb = 30
...@@ -231,6 +255,12 @@ func convertInstanceSettingFromRaw(instanceSettingRaw *InstanceSetting) (*storep ...@@ -231,6 +255,12 @@ func convertInstanceSettingFromRaw(instanceSettingRaw *InstanceSetting) (*storep
return nil, err return nil, err
} }
instanceSetting.Value = &storepb.InstanceSetting_MemoRelatedSetting{MemoRelatedSetting: memoRelatedSetting} instanceSetting.Value = &storepb.InstanceSetting_MemoRelatedSetting{MemoRelatedSetting: memoRelatedSetting}
case storepb.InstanceSettingKey_TAGS.String():
tagsSetting := &storepb.InstanceTagsSetting{}
if err := protojsonUnmarshaler.Unmarshal([]byte(instanceSettingRaw.Value), tagsSetting); err != nil {
return nil, err
}
instanceSetting.Value = &storepb.InstanceSetting_TagsSetting{TagsSetting: tagsSetting}
default: default:
// Skip unsupported instance setting key. // Skip unsupported instance setting key.
return nil, nil return nil, nil
......
...@@ -5,6 +5,7 @@ import ( ...@@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
colorpb "google.golang.org/genproto/googleapis/type/color"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
...@@ -220,6 +221,42 @@ func TestInstanceSettingStorageSetting(t *testing.T) { ...@@ -220,6 +221,42 @@ func TestInstanceSettingStorageSetting(t *testing.T) {
ts.Close() ts.Close()
} }
func TestInstanceSettingTagsSetting(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
tagsSetting, err := ts.GetInstanceTagsSetting(ctx)
require.NoError(t, err)
require.NotNil(t, tagsSetting)
require.Empty(t, tagsSetting.Tags)
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_TAGS,
Value: &storepb.InstanceSetting_TagsSetting{
TagsSetting: &storepb.InstanceTagsSetting{
Tags: map[string]*storepb.InstanceTagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 0.9,
Green: 0.1,
Blue: 0.1,
},
},
},
},
},
})
require.NoError(t, err)
tagsSetting, err = ts.GetInstanceTagsSetting(ctx)
require.NoError(t, err)
require.Contains(t, tagsSetting.Tags, "bug")
require.InDelta(t, 0.9, tagsSetting.Tags["bug"].GetBackgroundColor().GetRed(), 0.0001)
ts.Close()
}
func TestInstanceSettingListAll(t *testing.T) { func TestInstanceSettingListAll(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
......
...@@ -6,7 +6,6 @@ import ( ...@@ -6,7 +6,6 @@ import (
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
...@@ -105,48 +104,6 @@ func TestUserSettingUpsertUpdate(t *testing.T) { ...@@ -105,48 +104,6 @@ func TestUserSettingUpsertUpdate(t *testing.T) {
ts.Close() ts.Close()
} }
func TestUserSettingTags(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_TAGS,
Value: &storepb.UserSetting_Tags{
Tags: &storepb.TagsUserSetting{
Tags: map[string]*storepb.TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 0.1,
Green: 0.2,
Blue: 0.3,
},
},
},
},
},
})
require.NoError(t, err)
setting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_TAGS,
})
require.NoError(t, err)
require.NotNil(t, setting)
require.Contains(t, setting.GetTags().Tags, "bug")
require.InDelta(t, 0.1, setting.GetTags().Tags["bug"].GetBackgroundColor().GetRed(), 0.0001)
list, err := ts.ListUserSettings(ctx, &store.FindUserSetting{UserID: &user.ID})
require.NoError(t, err)
require.Len(t, list, 1)
ts.Close()
}
func TestUserSettingRefreshTokens(t *testing.T) { func TestUserSettingRefreshTokens(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
......
...@@ -431,12 +431,6 @@ func convertUserSettingFromRaw(raw *UserSetting) (*storepb.UserSetting, error) { ...@@ -431,12 +431,6 @@ func convertUserSettingFromRaw(raw *UserSetting) (*storepb.UserSetting, error) {
return nil, err return nil, err
} }
userSetting.Value = &storepb.UserSetting_Webhooks{Webhooks: webhooksUserSetting} userSetting.Value = &storepb.UserSetting_Webhooks{Webhooks: webhooksUserSetting}
case storepb.UserSetting_TAGS:
tagsUserSetting := &storepb.TagsUserSetting{}
if err := protojsonUnmarshaler.Unmarshal([]byte(raw.Value), tagsUserSetting); err != nil {
return nil, err
}
userSetting.Value = &storepb.UserSetting_Tags{Tags: tagsUserSetting}
default: default:
return nil, nil return nil, nil
} }
...@@ -485,13 +479,6 @@ func convertUserSettingToRaw(userSetting *storepb.UserSetting) (*UserSetting, er ...@@ -485,13 +479,6 @@ func convertUserSettingToRaw(userSetting *storepb.UserSetting) (*UserSetting, er
return nil, err return nil, err
} }
raw.Value = string(value) raw.Value = string(value)
case storepb.UserSetting_TAGS:
tagsUserSetting := userSetting.GetTags()
value, err := protojson.Marshal(tagsUserSetting)
if err != nil {
return nil, err
}
raw.Value = string(value)
default: default:
return nil, errors.Errorf("unsupported user setting key: %v", userSetting.Key) return nil, errors.Errorf("unsupported user setting key: %v", userSetting.Key)
} }
......
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