fix(api): align resource IDs with AIP conventions

Validate new user-provided IDs using the AIP-122 format while retaining legacy UID compatibility. Correct resource annotations and canonical names returned by user stats.
This commit is contained in:
johnnyjoygh
2026-07-18 11:12:28 +08:00
parent 715306ea66
commit 84776cc106
45 changed files with 368 additions and 192 deletions
+5 -1
View File
@@ -3,5 +3,9 @@ package base
import "regexp"
var (
UIDMatcher = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$")
// ResourceIDMatcher matches new user-provided resource IDs per AIP-122.
ResourceIDMatcher = regexp.MustCompile(`^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
// UIDMatcher also accepts the previous 36-character format so existing
// generated and persisted UIDs remain writable by the store layer.
UIDMatcher = regexp.MustCompile(`^(?:[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?|[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?)$`)
)
+27 -1
View File
@@ -1,6 +1,7 @@
package base
import (
"strings"
"testing"
)
@@ -23,7 +24,8 @@ func TestUIDMatcher(t *testing.T) {
{"abc123", true},
{"abc123-", false},
{"123e4567-e89b-12d3-a456-426614174000", true}, // UUID v4 from IDP
{"a123456789012345678901234567890123456", false}, // 37 characters (too long)
{"a123456789012345678901234567890123456", true}, // New AIP-compatible IDs can exceed the legacy limit.
{"A123456789012345678901234567890123456", false}, // Legacy uppercase IDs remain capped at 36 characters.
}
for _, test := range tests {
@@ -35,3 +37,27 @@ func TestUIDMatcher(t *testing.T) {
})
}
}
func TestResourceIDMatcher(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"a", true},
{"abc-123", true},
{"a" + strings.Repeat("b", 62), true},
{"a" + strings.Repeat("b", 63), false},
{"1abc", false},
{"Abc", false},
{"abc-", false},
{"abc_def", false},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
if got := ResourceIDMatcher.MatchString(test.input); got != test.expected {
t.Errorf("ResourceIDMatcher.MatchString(%q) = %v, want %v", test.input, got, test.expected)
}
})
}
}
+10
View File
@@ -1,6 +1,7 @@
package markdown
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -347,6 +348,15 @@ func TestExtractAllMentions(t *testing.T) {
require.NoError(t, err)
assert.ElementsMatch(t, []string{"alice", "bob"}, data.Mentions)
assert.ElementsMatch(t, []string{"tag"}, data.Tags)
maxLengthUsername := "a" + strings.Repeat("b", 62)
data, err = svc.ExtractAll([]byte("@" + maxLengthUsername))
require.NoError(t, err)
assert.Equal(t, []string{maxLengthUsername}, data.Mentions)
data, err = svc.ExtractAll([]byte("@" + maxLengthUsername + "c"))
require.NoError(t, err)
assert.Empty(t, data.Mentions)
}
func TestExtractAllSkipsTagsInsideLinks(t *testing.T) {
+2 -2
View File
@@ -13,7 +13,7 @@ import (
const (
// MaxMentionLength matches the username token length accepted by the API.
MaxMentionLength = 32
MaxMentionLength = 63
)
type mentionParser struct{}
@@ -66,7 +66,7 @@ func (*mentionParser) Parse(_ gast.Node, block text.Reader, _ parser.Context) ga
}
runeCount++
if runeCount > MaxMentionLength {
break
return nil
}
pos += size
}
+1
View File
@@ -117,6 +117,7 @@ message CreateAttachmentRequest {
// Optional. The attachment ID to use for this attachment.
// If empty, a unique ID will be generated.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
string attachment_id = 2 [(google.api.field_behavior) = OPTIONAL];
}
+1 -1
View File
@@ -65,7 +65,7 @@ message SignInRequest {
// Nested message for SSO authentication credentials.
message SSOCredentials {
// The resource name of the SSO provider.
// Format: identity-providers/{uid}
// Format: identity-providers/{idp}
string idp_name = 1 [(google.api.field_behavior) = REQUIRED];
// The authorization code from the SSO provider.
+1
View File
@@ -125,6 +125,7 @@ message CreateIdentityProviderRequest {
// Optional. The ID to use for the identity provider, which will become the final component of the resource name.
// If not provided, the system will generate one.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
string identity_provider_id = 2 [(google.api.field_behavior) = OPTIONAL];
}
+1
View File
@@ -296,6 +296,7 @@ message CreateMemoRequest {
// Optional. The memo ID to use for this memo.
// If empty, a unique ID will be generated.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
string memo_id = 2 [(google.api.field_behavior) = OPTIONAL];
}
+6 -6
View File
@@ -54,13 +54,13 @@ service ShortcutService {
message Shortcut {
option (google.api.resource) = {
type: "memos.api.v1/Shortcut"
pattern: "users/{username}/shortcuts/{shortcut}"
pattern: "users/{user}/shortcuts/{shortcut}"
singular: "shortcut"
plural: "shortcuts"
};
// The resource name of the shortcut.
// Format: users/{username}/shortcuts/{shortcut}
// Format: users/{user}/shortcuts/{shortcut}
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
// The title of the shortcut.
@@ -73,7 +73,7 @@ message Shortcut {
message ListShortcutsRequest {
// Required. The parent resource where shortcuts are listed.
// Format: users/{username}
// Format: users/{user}
string parent = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {child_type: "memos.api.v1/Shortcut"}
@@ -87,7 +87,7 @@ message ListShortcutsResponse {
message GetShortcutRequest {
// Required. The resource name of the shortcut to retrieve.
// Format: users/{username}/shortcuts/{shortcut}
// Format: users/{user}/shortcuts/{shortcut}
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Shortcut"}
@@ -96,7 +96,7 @@ message GetShortcutRequest {
message CreateShortcutRequest {
// Required. The parent resource where this shortcut will be created.
// Format: users/{username}
// Format: users/{user}
string parent = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {child_type: "memos.api.v1/Shortcut"}
@@ -119,7 +119,7 @@ message UpdateShortcutRequest {
message DeleteShortcutRequest {
// Required. The resource name of the shortcut to delete.
// Format: users/{username}/shortcuts/{shortcut}
// Format: users/{user}/shortcuts/{shortcut}
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Shortcut"}
+38 -19
View File
@@ -29,7 +29,7 @@ service UserService {
}
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
// Format: users/{user} (e.g., users/steven)
rpc GetUser(GetUserRequest) returns (User) {
option (google.api.http) = {get: "/api/v1/{name=users/*}"};
option (google.api.method_signature) = "name";
@@ -41,7 +41,7 @@ service UserService {
post: "/api/v1/users"
body: "user"
};
option (google.api.method_signature) = "user";
option (google.api.method_signature) = "user,user_id";
}
// UpdateUser updates a user.
@@ -293,7 +293,7 @@ message BatchGetUsersResponse {
message GetUserRequest {
// Required. The resource name of the user.
// Format: users/{username}
// Format: users/{user}
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/User"}
@@ -311,9 +311,9 @@ message CreateUserRequest {
(google.api.field_behavior) = INPUT_ONLY
];
// Optional. The user ID to use for this user.
// If empty, a unique ID will be generated.
// Must match the pattern [a-z0-9-]+
// Optional. The resource ID to use for this user. If set, it must equal
// user.username and follow the User resource ID format.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
string user_id = 2 [(google.api.field_behavior) = OPTIONAL];
// Optional. If set, validate the request but don't actually create the user.
@@ -351,13 +351,13 @@ message DeleteUserRequest {
message UserStats {
option (google.api.resource) = {
type: "memos.api.v1/UserStats"
pattern: "users/{user}"
pattern: "users/{user}/stats"
singular: "userStats"
plural: "userStats"
};
// The resource name of the user whose stats these are.
// Format: users/{user}
// The resource name of the user statistics singleton.
// Format: users/{user}/stats
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
reserved 2;
@@ -378,7 +378,7 @@ message UserStats {
repeated google.protobuf.Timestamp memo_updated_timestamps = 8;
// The pinned memos of the user.
repeated string pinned_memos = 5;
repeated string pinned_memos = 5 [(google.api.resource_reference) = {type: "memos.api.v1/Memo"}];
// Total memo count.
int32 total_memo_count = 6;
@@ -419,13 +419,13 @@ message ListAllUserStatsResponse {
message UserSetting {
option (google.api.resource) = {
type: "memos.api.v1/UserSetting"
pattern: "users/{username}/settings/{setting}"
pattern: "users/{user}/settings/{setting}"
singular: "userSetting"
plural: "userSettings"
};
// The name of the user setting.
// Format: users/{username}/settings/{setting}, {setting} is the key for the setting.
// Format: users/{user}/settings/{setting}, {setting} is the key for the setting.
// For example, "users/steven/settings/GENERAL" for general settings.
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
@@ -542,7 +542,7 @@ message LinkedIdentity {
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
// The resource name of the identity provider.
// Format: identity-providers/{uid}
// Format: identity-providers/{idp}
string idp_name = 2 [
(google.api.field_behavior) = OUTPUT_ONLY,
(google.api.resource_reference) = {type: "memos.api.v1/IdentityProvider"}
@@ -575,7 +575,7 @@ message CreateLinkedIdentityRequest {
];
// Required. The identity provider to link.
// Format: identity-providers/{uid}
// Format: identity-providers/{idp}
string idp_name = 2 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/IdentityProvider"}
@@ -694,9 +694,16 @@ message DeletePersonalAccessTokenRequest {
// UserWebhook represents a webhook owned by a user.
message UserWebhook {
option (google.api.resource) = {
type: "memos.api.v1/UserWebhook"
pattern: "users/{user}/webhooks/{webhook}"
singular: "userWebhook"
plural: "userWebhooks"
};
// The name of the webhook.
// Format: users/{user}/webhooks/{webhook}
string name = 1;
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
// The URL to send the webhook to.
string url = 2;
@@ -721,7 +728,10 @@ message UserWebhook {
message ListUserWebhooksRequest {
// The parent user resource.
// Format: users/{user}
string parent = 1 [(google.api.field_behavior) = REQUIRED];
string parent = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {child_type: "memos.api.v1/UserWebhook"}
];
}
message ListUserWebhooksResponse {
@@ -732,7 +742,10 @@ message ListUserWebhooksResponse {
message CreateUserWebhookRequest {
// The parent user resource.
// Format: users/{user}
string parent = 1 [(google.api.field_behavior) = REQUIRED];
string parent = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {child_type: "memos.api.v1/UserWebhook"}
];
// The webhook to create.
UserWebhook webhook = 2 [(google.api.field_behavior) = REQUIRED];
@@ -749,13 +762,19 @@ message UpdateUserWebhookRequest {
message DeleteUserWebhookRequest {
// The name of the webhook to delete.
// Format: users/{user}/webhooks/{webhook}
string name = 1 [(google.api.field_behavior) = REQUIRED];
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/UserWebhook"}
];
}
message GetUserWebhookSigningSecretRequest {
// The name of the webhook whose signing secret to reveal.
// Format: users/{user}/webhooks/{webhook}
string name = 1 [(google.api.field_behavior) = REQUIRED];
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/UserWebhook"}
];
}
message GetUserWebhookSigningSecretResponse {
@@ -116,7 +116,7 @@ type UserServiceClient interface {
// BatchGetUsers returns active users by usernames.
BatchGetUsers(context.Context, *connect.Request[v1.BatchGetUsersRequest]) (*connect.Response[v1.BatchGetUsersResponse], error)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
// Format: users/{user} (e.g., users/steven)
GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error)
// CreateUser creates a new user.
CreateUser(context.Context, *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.User], error)
@@ -507,7 +507,7 @@ type UserServiceHandler interface {
// BatchGetUsers returns active users by usernames.
BatchGetUsers(context.Context, *connect.Request[v1.BatchGetUsersRequest]) (*connect.Response[v1.BatchGetUsersResponse], error)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
// Format: users/{user} (e.g., users/steven)
GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error)
// CreateUser creates a new user.
CreateUser(context.Context, *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.User], error)
@@ -327,6 +327,7 @@ type CreateAttachmentRequest struct {
Attachment *Attachment `protobuf:"bytes,1,opt,name=attachment,proto3" json:"attachment,omitempty"`
// Optional. The attachment ID to use for this attachment.
// If empty, a unique ID will be generated.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
AttachmentId string `protobuf:"bytes,2,opt,name=attachment_id,json=attachmentId,proto3" json:"attachment_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
+1 -1
View File
@@ -441,7 +441,7 @@ func (x *SignInRequest_PasswordCredentials) GetPassword() string {
type SignInRequest_SSOCredentials struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The resource name of the SSO provider.
// Format: identity-providers/{uid}
// Format: identity-providers/{idp}
IdpName string `protobuf:"bytes,1,opt,name=idp_name,json=idpName,proto3" json:"idp_name,omitempty"`
// The authorization code from the SSO provider.
Code string `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"`
+1
View File
@@ -512,6 +512,7 @@ type CreateIdentityProviderRequest struct {
IdentityProvider *IdentityProvider `protobuf:"bytes,1,opt,name=identity_provider,json=identityProvider,proto3" json:"identity_provider,omitempty"`
// Optional. The ID to use for the identity provider, which will become the final component of the resource name.
// If not provided, the system will generate one.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
IdentityProviderId string `protobuf:"bytes,2,opt,name=identity_provider_id,json=identityProviderId,proto3" json:"identity_provider_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
+1
View File
@@ -472,6 +472,7 @@ type CreateMemoRequest struct {
Memo *Memo `protobuf:"bytes,1,opt,name=memo,proto3" json:"memo,omitempty"`
// Optional. The memo ID to use for this memo.
// If empty, a unique ID will be generated.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
MemoId string `protobuf:"bytes,2,opt,name=memo_id,json=memoId,proto3" json:"memo_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
+8 -8
View File
@@ -27,7 +27,7 @@ const (
type Shortcut struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The resource name of the shortcut.
// Format: users/{username}/shortcuts/{shortcut}
// Format: users/{user}/shortcuts/{shortcut}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// The title of the shortcut.
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
@@ -92,7 +92,7 @@ func (x *Shortcut) GetFilter() string {
type ListShortcutsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The parent resource where shortcuts are listed.
// Format: users/{username}
// Format: users/{user}
Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -183,7 +183,7 @@ func (x *ListShortcutsResponse) GetShortcuts() []*Shortcut {
type GetShortcutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the shortcut to retrieve.
// Format: users/{username}/shortcuts/{shortcut}
// Format: users/{user}/shortcuts/{shortcut}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -229,7 +229,7 @@ func (x *GetShortcutRequest) GetName() string {
type CreateShortcutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The parent resource where this shortcut will be created.
// Format: users/{username}
// Format: users/{user}
Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
// Required. The shortcut to create.
Shortcut *Shortcut `protobuf:"bytes,2,opt,name=shortcut,proto3" json:"shortcut,omitempty"`
@@ -347,7 +347,7 @@ func (x *UpdateShortcutRequest) GetUpdateMask() *fieldmaskpb.FieldMask {
type DeleteShortcutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the shortcut to delete.
// Format: users/{username}/shortcuts/{shortcut}
// Format: users/{user}/shortcuts/{shortcut}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -394,12 +394,12 @@ var File_api_v1_shortcut_service_proto protoreflect.FileDescriptor
const file_api_v1_shortcut_service_proto_rawDesc = "" +
"\n" +
"\x1dapi/v1/shortcut_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\"\xb3\x01\n" +
"\x1dapi/v1/shortcut_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\"\xaf\x01\n" +
"\bShortcut\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x19\n" +
"\x05title\x18\x02 \x01(\tB\x03\xe0A\x02R\x05title\x12\x1b\n" +
"\x06filter\x18\x03 \x01(\tB\x03\xe0A\x01R\x06filter:V\xeaAS\n" +
"\x15memos.api.v1/Shortcut\x12%users/{username}/shortcuts/{shortcut}*\tshortcuts2\bshortcut\"M\n" +
"\x06filter\x18\x03 \x01(\tB\x03\xe0A\x01R\x06filter:R\xeaAO\n" +
"\x15memos.api.v1/Shortcut\x12!users/{user}/shortcuts/{shortcut}*\tshortcuts2\bshortcut\"M\n" +
"\x14ListShortcutsRequest\x125\n" +
"\x06parent\x18\x01 \x01(\tB\x1d\xe0A\x02\xfaA\x17\x12\x15memos.api.v1/ShortcutR\x06parent\"M\n" +
"\x15ListShortcutsResponse\x124\n" +
+38 -34
View File
@@ -593,7 +593,7 @@ func (x *BatchGetUsersResponse) GetUsers() []*User {
type GetUserRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the user.
// Format: users/{username}
// Format: users/{user}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// Optional. The fields to return in the response.
// If not specified, all fields are returned.
@@ -650,9 +650,9 @@ type CreateUserRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The user to create.
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
// Optional. The user ID to use for this user.
// If empty, a unique ID will be generated.
// Must match the pattern [a-z0-9-]+
// Optional. The resource ID to use for this user. If set, it must equal
// user.username and follow the User resource ID format.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
// Optional. If set, validate the request but don't actually create the user.
ValidateOnly bool `protobuf:"varint,3,opt,name=validate_only,json=validateOnly,proto3" json:"validate_only,omitempty"`
@@ -842,8 +842,8 @@ func (x *DeleteUserRequest) GetForce() bool {
// User statistics messages
type UserStats struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The resource name of the user whose stats these are.
// Format: users/{user}
// The resource name of the user statistics singleton.
// Format: users/{user}/stats
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// The stats of memo types.
MemoTypeStats *UserStats_MemoTypeStats `protobuf:"bytes,3,opt,name=memo_type_stats,json=memoTypeStats,proto3" json:"memo_type_stats,omitempty"`
@@ -1092,7 +1092,7 @@ func (x *ListAllUserStatsResponse) GetStats() []*UserStats {
type UserSetting struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The name of the user setting.
// Format: users/{username}/settings/{setting}, {setting} is the key for the setting.
// Format: users/{user}/settings/{setting}, {setting} is the key for the setting.
// For example, "users/steven/settings/GENERAL" for general settings.
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// Types that are valid to be assigned to Value:
@@ -1430,7 +1430,7 @@ type LinkedIdentity struct {
// Format: users/{user}/linkedIdentities/{linked_identity}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// The resource name of the identity provider.
// Format: identity-providers/{uid}
// Format: identity-providers/{idp}
IdpName string `protobuf:"bytes,2,opt,name=idp_name,json=idpName,proto3" json:"idp_name,omitempty"`
// The external user identifier from the identity provider.
ExternUid string `protobuf:"bytes,3,opt,name=extern_uid,json=externUid,proto3" json:"extern_uid,omitempty"`
@@ -1586,7 +1586,7 @@ type CreateLinkedIdentityRequest struct {
// Format: users/{user}
Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
// Required. The identity provider to link.
// Format: identity-providers/{uid}
// Format: identity-providers/{idp}
IdpName string `protobuf:"bytes,2,opt,name=idp_name,json=idpName,proto3" json:"idp_name,omitempty"`
// Required. The authorization code from the identity provider.
Code string `protobuf:"bytes,3,opt,name=code,proto3" json:"code,omitempty"`
@@ -3407,14 +3407,15 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x11DeleteUserRequest\x12-\n" +
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
"\x11memos.api.v1/UserR\x04name\x12\x19\n" +
"\x05force\x18\x02 \x01(\bB\x03\xe0A\x01R\x05force\"\xd7\x05\n" +
"\x05force\x18\x02 \x01(\bB\x03\xe0A\x01R\x05force\"\xf5\x05\n" +
"\tUserStats\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12M\n" +
"\x0fmemo_type_stats\x18\x03 \x01(\v2%.memos.api.v1.UserStats.MemoTypeStatsR\rmemoTypeStats\x12B\n" +
"\ttag_count\x18\x04 \x03(\v2%.memos.api.v1.UserStats.TagCountEntryR\btagCount\x12R\n" +
"\x17memo_created_timestamps\x18\a \x03(\v2\x1a.google.protobuf.TimestampR\x15memoCreatedTimestamps\x12R\n" +
"\x17memo_updated_timestamps\x18\b \x03(\v2\x1a.google.protobuf.TimestampR\x15memoUpdatedTimestamps\x12!\n" +
"\fpinned_memos\x18\x05 \x03(\tR\vpinnedMemos\x12(\n" +
"\x17memo_updated_timestamps\x18\b \x03(\v2\x1a.google.protobuf.TimestampR\x15memoUpdatedTimestamps\x129\n" +
"\fpinned_memos\x18\x05 \x03(\tB\x16\xfaA\x13\n" +
"\x11memos.api.v1/MemoR\vpinnedMemos\x12(\n" +
"\x10total_memo_count\x18\x06 \x01(\x05R\x0etotalMemoCount\x1a;\n" +
"\rTagCountEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
@@ -3427,8 +3428,8 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\n" +
"todo_count\x18\x03 \x01(\x05R\ttodoCount\x12\x1d\n" +
"\n" +
"undo_count\x18\x04 \x01(\x05R\tundoCount:?\xeaA<\n" +
"\x16memos.api.v1/UserStats\x12\fusers/{user}*\tuserStats2\tuserStatsJ\x04\b\x02\x10\x03R\x17memo_display_timestamps\"D\n" +
"undo_count\x18\x04 \x01(\x05R\tundoCount:E\xeaAB\n" +
"\x16memos.api.v1/UserStats\x12\x12users/{user}/stats*\tuserStats2\tuserStatsJ\x04\b\x02\x10\x03R\x17memo_display_timestamps\"D\n" +
"\x13GetUserStatsRequest\x12-\n" +
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
"\x11memos.api.v1/UserR\x04name\"f\n" +
@@ -3436,7 +3437,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x05state\x18\x01 \x01(\x0e2\x13.memos.api.v1.StateB\x03\xe0A\x01R\x05state\x12\x1b\n" +
"\x06filter\x18\x02 \x01(\tB\x03\xe0A\x01R\x06filter\"I\n" +
"\x18ListAllUserStatsResponse\x12-\n" +
"\x05stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\x05stats\"\xbf\a\n" +
"\x05stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\x05stats\"\xbb\a\n" +
"\vUserSetting\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12S\n" +
"\x0fgeneral_setting\x18\x02 \x01(\v2(.memos.api.v1.UserSetting.GeneralSettingH\x00R\x0egeneralSetting\x12V\n" +
@@ -3460,8 +3461,8 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x0fKEY_UNSPECIFIED\x10\x00\x12\v\n" +
"\aGENERAL\x10\x01\x12\f\n" +
"\bWEBHOOKS\x10\x04\x12\b\n" +
"\x04TAGS\x10\x05:]\xeaAZ\n" +
"\x18memos.api.v1/UserSetting\x12#users/{username}/settings/{setting}*\fuserSettings2\vuserSettingB\a\n" +
"\x04TAGS\x10\x05:Y\xeaAV\n" +
"\x18memos.api.v1/UserSetting\x12\x1fusers/{user}/settings/{setting}*\fuserSettings2\vuserSettingB\a\n" +
"\x05value\"M\n" +
"\x15GetUserSettingRequest\x124\n" +
"\x04name\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\n" +
@@ -3534,9 +3535,9 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x05token\x18\x02 \x01(\tR\x05token\"`\n" +
" DeletePersonalAccessTokenRequest\x12<\n" +
"\x04name\x18\x01 \x01(\tB(\xe0A\x02\xfaA\"\n" +
" memos.api.v1/PersonalAccessTokenR\x04name\"\xb9\x02\n" +
"\vUserWebhook\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n" +
" memos.api.v1/PersonalAccessTokenR\x04name\"\x99\x03\n" +
"\vUserWebhook\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x10\n" +
"\x03url\x18\x02 \x01(\tR\x03url\x12!\n" +
"\fdisplay_name\x18\x03 \x01(\tR\vdisplayName\x12@\n" +
"\vcreate_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
@@ -3544,22 +3545,25 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\vupdate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
"updateTime\x12*\n" +
"\x0esigning_secret\x18\x06 \x01(\tB\x03\xe0A\x04R\rsigningSecret\x121\n" +
"\x12signing_secret_set\x18\a \x01(\bB\x03\xe0A\x03R\x10signingSecretSet\"6\n" +
"\x17ListUserWebhooksRequest\x12\x1b\n" +
"\x06parent\x18\x01 \x01(\tB\x03\xe0A\x02R\x06parent\"Q\n" +
"\x12signing_secret_set\x18\a \x01(\bB\x03\xe0A\x03R\x10signingSecretSet:Y\xeaAV\n" +
"\x18memos.api.v1/UserWebhook\x12\x1fusers/{user}/webhooks/{webhook}*\fuserWebhooks2\vuserWebhook\"S\n" +
"\x17ListUserWebhooksRequest\x128\n" +
"\x06parent\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\x12\x18memos.api.v1/UserWebhookR\x06parent\"Q\n" +
"\x18ListUserWebhooksResponse\x125\n" +
"\bwebhooks\x18\x01 \x03(\v2\x19.memos.api.v1.UserWebhookR\bwebhooks\"q\n" +
"\x18CreateUserWebhookRequest\x12\x1b\n" +
"\x06parent\x18\x01 \x01(\tB\x03\xe0A\x02R\x06parent\x128\n" +
"\bwebhooks\x18\x01 \x03(\v2\x19.memos.api.v1.UserWebhookR\bwebhooks\"\x8e\x01\n" +
"\x18CreateUserWebhookRequest\x128\n" +
"\x06parent\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\x12\x18memos.api.v1/UserWebhookR\x06parent\x128\n" +
"\awebhook\x18\x02 \x01(\v2\x19.memos.api.v1.UserWebhookB\x03\xe0A\x02R\awebhook\"\x91\x01\n" +
"\x18UpdateUserWebhookRequest\x128\n" +
"\awebhook\x18\x01 \x01(\v2\x19.memos.api.v1.UserWebhookB\x03\xe0A\x02R\awebhook\x12;\n" +
"\vupdate_mask\x18\x02 \x01(\v2\x1a.google.protobuf.FieldMaskR\n" +
"updateMask\"3\n" +
"\x18DeleteUserWebhookRequest\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\x02R\x04name\"=\n" +
"\"GetUserWebhookSigningSecretRequest\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\x02R\x04name\"L\n" +
"updateMask\"P\n" +
"\x18DeleteUserWebhookRequest\x124\n" +
"\x04name\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\n" +
"\x18memos.api.v1/UserWebhookR\x04name\"Z\n" +
"\"GetUserWebhookSigningSecretRequest\x124\n" +
"\x04name\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\n" +
"\x18memos.api.v1/UserWebhookR\x04name\"L\n" +
"#GetUserWebhookSigningSecretResponse\x12%\n" +
"\x0esigning_secret\x18\x01 \x01(\tR\rsigningSecret\"\xda\b\n" +
"\x10UserNotification\x12\x1a\n" +
@@ -3611,13 +3615,13 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"updateMask\"Z\n" +
"\x1dDeleteUserNotificationRequest\x129\n" +
"\x04name\x18\x01 \x01(\tB%\xe0A\x02\xfaA\x1f\n" +
"\x1dmemos.api.v1/UserNotificationR\x04name2\xca\x1e\n" +
"\x1dmemos.api.v1/UserNotificationR\x04name2\xd2\x1e\n" +
"\vUserService\x12c\n" +
"\tListUsers\x12\x1e.memos.api.v1.ListUsersRequest\x1a\x1f.memos.api.v1.ListUsersResponse\"\x15\x82\xd3\xe4\x93\x02\x0f\x12\r/api/v1/users\x12{\n" +
"\rBatchGetUsers\x12\".memos.api.v1.BatchGetUsersRequest\x1a#.memos.api.v1.BatchGetUsersResponse\"!\x82\xd3\xe4\x93\x02\x1b:\x01*\"\x16/api/v1/users:batchGet\x12b\n" +
"\aGetUser\x12\x1c.memos.api.v1.GetUserRequest\x1a\x12.memos.api.v1.User\"%\xdaA\x04name\x82\xd3\xe4\x93\x02\x18\x12\x16/api/v1/{name=users/*}\x12e\n" +
"\aGetUser\x12\x1c.memos.api.v1.GetUserRequest\x1a\x12.memos.api.v1.User\"%\xdaA\x04name\x82\xd3\xe4\x93\x02\x18\x12\x16/api/v1/{name=users/*}\x12m\n" +
"\n" +
"CreateUser\x12\x1f.memos.api.v1.CreateUserRequest\x1a\x12.memos.api.v1.User\"\"\xdaA\x04user\x82\xd3\xe4\x93\x02\x15:\x04user\"\r/api/v1/users\x12\x7f\n" +
"CreateUser\x12\x1f.memos.api.v1.CreateUserRequest\x1a\x12.memos.api.v1.User\"*\xdaA\fuser,user_id\x82\xd3\xe4\x93\x02\x15:\x04user\"\r/api/v1/users\x12\x7f\n" +
"\n" +
"UpdateUser\x12\x1f.memos.api.v1.UpdateUserRequest\x1a\x12.memos.api.v1.User\"<\xdaA\x10user,update_mask\x82\xd3\xe4\x93\x02#:\x04user2\x1b/api/v1/{user.name=users/*}\x12l\n" +
"\n" +
+2 -2
View File
@@ -57,7 +57,7 @@ type UserServiceClient interface {
// BatchGetUsers returns active users by usernames.
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
// Format: users/{user} (e.g., users/steven)
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
// CreateUser creates a new user.
CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*User, error)
@@ -388,7 +388,7 @@ type UserServiceServer interface {
// BatchGetUsers returns active users by usernames.
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
// Format: users/{user} (e.g., users/steven)
GetUser(context.Context, *GetUserRequest) (*User, error)
// CreateUser creates a new user.
CreateUser(context.Context, *CreateUserRequest) (*User, error)
+14 -11
View File
@@ -95,6 +95,7 @@ paths:
description: |-
Optional. The attachment ID to use for this attachment.
If empty, a unique ID will be generated.
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
schema:
type: string
requestBody:
@@ -349,6 +350,7 @@ paths:
description: |-
Optional. The ID to use for the identity provider, which will become the final component of the resource name.
If not provided, the system will generate one.
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
schema:
type: string
requestBody:
@@ -703,6 +705,7 @@ paths:
description: |-
Optional. The memo ID to use for this memo.
If empty, a unique ID will be generated.
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
schema:
type: string
requestBody:
@@ -1351,9 +1354,9 @@ paths:
- name: userId
in: query
description: |-
Optional. The user ID to use for this user.
If empty, a unique ID will be generated.
Must match the pattern [a-z0-9-]+
Optional. The resource ID to use for this user. If set, it must equal
user.username and follow the User resource ID format.
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
schema:
type: string
- name: validateOnly
@@ -1393,7 +1396,7 @@ paths:
- UserService
description: |-
GetUser gets a user by username.
Format: users/{username} (e.g., users/steven)
Format: users/{user} (e.g., users/steven)
operationId: UserService_GetUser
parameters:
- name: user
@@ -2638,7 +2641,7 @@ components:
type: string
description: |-
Required. The identity provider to link.
Format: identity-providers/{uid}
Format: identity-providers/{idp}
code:
type: string
description: Required. The authorization code from the identity provider.
@@ -3031,7 +3034,7 @@ components:
type: string
description: |-
The resource name of the identity provider.
Format: identity-providers/{uid}
Format: identity-providers/{idp}
externUid:
readOnly: true
type: string
@@ -3568,7 +3571,7 @@ components:
type: string
description: |-
The resource name of the shortcut.
Format: users/{username}/shortcuts/{shortcut}
Format: users/{user}/shortcuts/{shortcut}
title:
type: string
description: The title of the shortcut.
@@ -3612,7 +3615,7 @@ components:
type: string
description: |-
The resource name of the SSO provider.
Format: identity-providers/{uid}
Format: identity-providers/{idp}
code:
type: string
description: The authorization code from the SSO provider.
@@ -3894,7 +3897,7 @@ components:
type: string
description: |-
The name of the user setting.
Format: users/{username}/settings/{setting}, {setting} is the key for the setting.
Format: users/{user}/settings/{setting}, {setting} is the key for the setting.
For example, "users/steven/settings/GENERAL" for general settings.
generalSetting:
$ref: '#/components/schemas/UserSetting_GeneralSetting'
@@ -3958,8 +3961,8 @@ components:
name:
type: string
description: |-
The resource name of the user whose stats these are.
Format: users/{user}
The resource name of the user statistics singleton.
Format: users/{user}/stats
memoTypeStats:
allOf:
- $ref: '#/components/schemas/UserStats_MemoTypeStats'
+6 -8
View File
@@ -19,7 +19,7 @@ var PublicMethods = map[string]struct{}{
"/memos.api.v1.InstanceService/BatchGetInstanceSettings": {},
// User Service - public user profiles and stats
"/memos.api.v1.UserService/CreateUser": {}, // Allow first user registration
"/memos.api.v1.UserService/CreateUser": {}, // Registration policy is enforced in UserService
"/memos.api.v1.UserService/GetUser": {},
"/memos.api.v1.UserService/BatchGetUsers": {},
"/memos.api.v1.UserService/GetUserAvatar": {},
@@ -51,9 +51,8 @@ func IsPublicMethod(procedure string) bool {
// anonymous callers even when the instance is private (no InstanceURL configured).
//
// It is the minimum required to render the sign-in page, authenticate, and follow
// share links. Every entry here MUST also exist in PublicMethods. CreateUser is
// intentionally excluded and handled separately (allowed only during first-run
// setup, while the instance has no users yet).
// share links, and register when instance settings permit it. Every entry here
// MUST also exist in PublicMethods.
var AuthBootstrapMethods = map[string]struct{}{
// Auth Service - sign-in and token refresh.
"/memos.api.v1.AuthService/SignIn": {},
@@ -67,14 +66,13 @@ var AuthBootstrapMethods = map[string]struct{}{
// Identity Provider Service - SSO buttons on the sign-in page.
"/memos.api.v1.IdentityProviderService/ListIdentityProviders": {},
// User Service - CreateUser applies registration and password-auth settings.
"/memos.api.v1.UserService/CreateUser": {},
// Memo sharing - share-token access stays public even on a private instance.
"/memos.api.v1.MemoService/GetMemoByShare": {},
}
// createUserProcedure is the CreateUser endpoint. On a private instance it is
// served to anonymous callers only while no user exists yet (initial admin setup).
const createUserProcedure = "/memos.api.v1.UserService/CreateUser"
// IsAuthBootstrapMethod reports whether an anonymous request to procedure is one
// of the fixed endpoints allowed while the instance is private.
func IsAuthBootstrapMethod(procedure string) bool {
+2 -3
View File
@@ -103,10 +103,11 @@ func TestAuthBootstrapMethodsAreSubsetOfPublic(t *testing.T) {
// TestAuthBootstrapClassification verifies which endpoints remain reachable by
// anonymous callers on a private instance (no InstanceURL configured).
func TestAuthBootstrapClassification(t *testing.T) {
// Reachable while private: sign-in flow, instance metadata, SSO, share links.
// Reachable while private: sign-in flow, registration, instance metadata, SSO, share links.
bootstrap := []string{
"/memos.api.v1.AuthService/SignIn",
"/memos.api.v1.AuthService/RefreshToken",
"/memos.api.v1.UserService/CreateUser",
"/memos.api.v1.InstanceService/GetInstanceProfile",
"/memos.api.v1.InstanceService/GetInstanceSetting",
"/memos.api.v1.InstanceService/BatchGetInstanceSettings",
@@ -120,14 +121,12 @@ func TestAuthBootstrapClassification(t *testing.T) {
}
// Public on an open instance, but gated on a private one: browsing and profiles.
// CreateUser is gated here too; it is allowed separately only during first-run setup.
gatedWhilePrivate := []string{
"/memos.api.v1.MemoService/ListMemos",
"/memos.api.v1.MemoService/GetMemo",
"/memos.api.v1.MemoService/ListMemoComments",
"/memos.api.v1.UserService/GetUser",
"/memos.api.v1.UserService/ListAllUserStats",
"/memos.api.v1.UserService/CreateUser",
}
for _, method := range gatedWhilePrivate {
t.Run("gated/"+method, func(t *testing.T) {
+1 -1
View File
@@ -112,7 +112,7 @@ func (s *APIV1Service) SignIn(ctx context.Context, request *v1pb.SignInRequest)
//
// Lookup goes through the user_identity table so that userInfo.Identifier is never used
// as the local username key. On the miss path, a local user is created with a
// UUID-based local username (see deriveSSOUsername) and the (provider, extern_uid)
// UUID-backed local username (see deriveSSOUsername) and the (provider, extern_uid)
// linkage is inserted in the same flow. When currentUser is provided by a caller
// outside AuthService.SignIn, the lookup miss path binds the external identity to
// that existing user instead. If the linkage insert loses a race on the unique
+4 -21
View File
@@ -25,7 +25,6 @@ var ErrUnauthenticated = errors.New("authentication required")
// governs only authentication and anonymous access.
type Authorizer struct {
authenticator *auth.Authenticator
store *store.Store
profile *profile.Profile
}
@@ -34,7 +33,6 @@ type Authorizer struct {
func NewAuthorizer(store *store.Store, secret string, profile *profile.Profile) *Authorizer {
return &Authorizer{
authenticator: auth.NewAuthenticator(store, secret),
store: store,
profile: profile,
}
}
@@ -54,7 +52,7 @@ func (a *Authorizer) Authenticate(ctx context.Context, authHeader string) *auth.
// - Anonymous + protected method: denied.
// - Anonymous + public method, open instance: permitted.
// - Anonymous + public method, private instance (no InstanceURL): permitted only
// for the auth-bootstrap set, plus CreateUser during first-run setup.
// for the auth-bootstrap set.
func (a *Authorizer) CheckAccess(ctx context.Context, procedure string, result *auth.AuthResult) error {
if result != nil {
return nil
@@ -69,22 +67,7 @@ func (a *Authorizer) CheckAccess(ctx context.Context, procedure string, result *
}
// allowedOnPrivateInstance reports whether an anonymous request to a public
// procedure is still permitted while the instance is private. It allows the
// auth-bootstrap set, plus CreateUser while the instance has no users yet
// (first-run admin setup).
func (a *Authorizer) allowedOnPrivateInstance(ctx context.Context, procedure string) bool {
if IsAuthBootstrapMethod(procedure) {
return true
}
if procedure == createUserProcedure {
return a.noUsersExist(ctx)
}
return false
}
// noUsersExist reports whether the instance has no users yet (fresh install).
func (a *Authorizer) noUsersExist(ctx context.Context) bool {
limitOne := 1
users, err := a.store.ListUsers(ctx, &store.FindUser{Limit: &limitOne})
return err == nil && len(users) == 0
// procedure is still permitted while the instance is private.
func (*Authorizer) allowedOnPrivateInstance(_ context.Context, procedure string) bool {
return IsAuthBootstrapMethod(procedure)
}
+3 -2
View File
@@ -12,8 +12,7 @@ import (
// TestAuthorizerCheckAccess exercises the method-level access policy matrix.
//
// The store-backed first-run CreateUser branch is covered by integration tests;
// every case here is decided without touching the store, so a nil store is safe.
// Every case here is decided without touching the store, so a nil store is safe.
func TestAuthorizerCheckAccess(t *testing.T) {
ctx := context.Background()
authenticated := &auth.AuthResult{AccessToken: "token"}
@@ -25,6 +24,7 @@ func TestAuthorizerCheckAccess(t *testing.T) {
protectedMethod = "/memos.api.v1.MemoService/CreateMemo"
publicMethod = "/memos.api.v1.MemoService/ListMemos"
bootstrapMethod = "/memos.api.v1.AuthService/SignIn"
createUser = "/memos.api.v1.UserService/CreateUser"
shareMethod = "/memos.api.v1.MemoService/GetMemoByShare"
)
@@ -41,6 +41,7 @@ func TestAuthorizerCheckAccess(t *testing.T) {
{"anonymous allowed on public method, open instance", openInstance, publicMethod, nil, false},
{"anonymous denied on public method, private instance", privateInstance, publicMethod, nil, true},
{"anonymous allowed on bootstrap method, private instance", privateInstance, bootstrapMethod, nil, false},
{"anonymous allowed to register on private instance", privateInstance, createUser, nil, false},
{"anonymous allowed on share access, private instance", privateInstance, shareMethod, nil, false},
}
for _, c := range cases {
+3 -3
View File
@@ -136,14 +136,14 @@ func ExtractIdentityProviderUIDFromName(name string) (string, error) {
// ValidateAndGenerateUID validates a user-provided UID or generates a new one.
// If provided is empty, a new shortuuid is generated.
// If provided is non-empty, it is validated against base.UIDMatcher.
// If provided is non-empty, it is validated as a user-provided resource ID.
func ValidateAndGenerateUID(provided string) (string, error) {
uid := strings.TrimSpace(provided)
if uid == "" {
return shortuuid.New(), nil
}
if !base.UIDMatcher.MatchString(uid) {
return "", status.Errorf(codes.InvalidArgument, "invalid ID format: must be 1-36 characters, alphanumeric and hyphens only, cannot start or end with hyphen")
if !base.ResourceIDMatcher.MatchString(uid) {
return "", status.Errorf(codes.InvalidArgument, "invalid resource ID: must be 1-63 characters, start with a lowercase letter, contain only lowercase letters, digits, or hyphens, and end with a letter or digit")
}
return uid, nil
}
@@ -0,0 +1,38 @@
package v1
import (
"strings"
"testing"
)
func TestValidateAndGenerateUIDValidatesUserProvidedResourceIDs(t *testing.T) {
tests := []struct {
name string
provided string
wantError bool
}{
{name: "lowercase", provided: "memo-1"},
{name: "maximum length", provided: "a" + strings.Repeat("b", 62)},
{name: "digit first", provided: "1-memo", wantError: true},
{name: "uppercase", provided: "Memo", wantError: true},
{name: "too long", provided: "a" + strings.Repeat("b", 63), wantError: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
uid, err := ValidateAndGenerateUID(test.provided)
if test.wantError {
if err == nil {
t.Fatalf("ValidateAndGenerateUID(%q) succeeded, want error", test.provided)
}
return
}
if err != nil {
t.Fatalf("ValidateAndGenerateUID(%q) returned error: %v", test.provided, err)
}
if uid != test.provided {
t.Fatalf("ValidateAndGenerateUID(%q) = %q", test.provided, uid)
}
})
}
}
+4 -5
View File
@@ -8,13 +8,12 @@ import (
// deriveSSOUsername produces the local username for a new SSO-created user.
//
// The current policy is to use a standard UUID string directly. This keeps the
// username independent of IdP profile fields and avoids availability probes or
// retry loops around concurrent first-time logins.
// The current policy prefixes a UUID with a letter so the generated value
// follows the same AIP-compatible format as user-selected usernames.
func deriveSSOUsername() (string, error) {
username := util.GenUUID()
username := "user-" + util.GenUUID()
if err := validateWritableUsername(username); err != nil {
return "", errors.Wrap(err, "generated UUID did not satisfy username constraints")
return "", errors.Wrap(err, "generated username did not satisfy username constraints")
}
return username, nil
}
+19
View File
@@ -0,0 +1,19 @@
package v1
import (
"strings"
"testing"
)
func TestDeriveSSOUsername(t *testing.T) {
username, err := deriveSSOUsername()
if err != nil {
t.Fatalf("deriveSSOUsername() returned error: %v", err)
}
if !strings.HasPrefix(username, "user-") {
t.Fatalf("deriveSSOUsername() = %q, want user- prefix", username)
}
if err := validateWritableUsername(username); err != nil {
t.Fatalf("deriveSSOUsername() produced invalid username %q: %v", username, err)
}
}
+9 -9
View File
@@ -14,10 +14,10 @@ import (
apiv1 "github.com/usememos/memos/server/router/api/v1"
)
// TestAuthorizerPrivateInstanceFirstRun verifies the private-instance access policy
// against a real store: anonymous CreateUser is permitted only until the first user
// exists, bootstrap methods stay open, and other public methods are gated.
func TestAuthorizerPrivateInstanceFirstRun(t *testing.T) {
// TestAuthorizerPrivateInstanceRegistration verifies that registration and other
// bootstrap methods stay reachable anonymously while unrelated public methods are
// gated. CreateUser enforces the instance registration settings in the service.
func TestAuthorizerPrivateInstanceRegistration(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
@@ -35,17 +35,17 @@ func TestAuthorizerPrivateInstanceFirstRun(t *testing.T) {
// Anonymous request with no Authorization header resolves to no identity.
require.Nil(t, authorizer.Authenticate(ctx, ""))
// Fresh instance (no users): first-run CreateUser is allowed, and so are the
// bootstrap methods; browsing is still gated.
require.NoError(t, authorizer.CheckAccess(ctx, createUser, nil), "first-run CreateUser should be allowed")
// Registration and other bootstrap methods are allowed; browsing is still gated.
require.NoError(t, authorizer.CheckAccess(ctx, createUser, nil))
require.NoError(t, authorizer.CheckAccess(ctx, signIn, nil))
require.NoError(t, authorizer.CheckAccess(ctx, getMemoShare, nil))
require.ErrorIs(t, authorizer.CheckAccess(ctx, listMemos, nil), apiv1.ErrUnauthenticated)
// Once a user exists, anonymous CreateUser is denied while bootstrap stays open.
// Once a user exists, CreateUser remains reachable so UserService can enforce
// disallow_user_registration and disallow_password_auth.
_, err := ts.CreateHostUser(ctx, "host")
require.NoError(t, err)
require.ErrorIs(t, authorizer.CheckAccess(ctx, createUser, nil), apiv1.ErrUnauthenticated, "CreateUser must be denied once a user exists")
require.NoError(t, authorizer.CheckAccess(ctx, createUser, nil))
require.NoError(t, authorizer.CheckAccess(ctx, signIn, nil))
}
@@ -256,6 +256,22 @@ func TestCreateUserRegistration(t *testing.T) {
require.Contains(t, err.Error(), "invalid username")
})
t.Run("CreateUser requires user_id to match username", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
_, err := ts.Service.CreateUser(ctx, &apiv1.CreateUserRequest{
User: &apiv1.User{
Username: "alice",
Email: "alice@example.com",
Password: "password123",
},
UserId: "bob",
})
require.Error(t, err)
require.Contains(t, err.Error(), "user_id must match user.username")
})
t.Run("UpdateUser rejects empty password", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
@@ -159,6 +159,29 @@ func TestGetUserStats_MemoUpdatedTimestamps(t *testing.T) {
)
}
func TestGetUserStats_PinnedMemoUsesCanonicalResourceName(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateHostUser(ctx, "pinned-stats-user")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
memo, err := ts.Store.CreateMemo(ctx, &store.Memo{
UID: "pinned-stats-memo",
CreatorID: user.ID,
Content: "pinned",
Visibility: store.Public,
})
require.NoError(t, err)
pinned := true
require.NoError(t, ts.Store.UpdateMemo(ctx, &store.UpdateMemo{ID: memo.ID, Pinned: &pinned}))
resp, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{Name: fmt.Sprintf("users/%s", user.Username)})
require.NoError(t, err)
require.Equal(t, []string{"memos/pinned-stats-memo"}, resp.PinnedMemos)
}
func TestListAllUserStats_FilterExcludesPrivateMemos(t *testing.T) {
ctx := context.Background()
+2 -14
View File
@@ -27,24 +27,12 @@ func parseUsernameFromName(name string) (string, error) {
}
func validateWritableUsername(username string) error {
if username == "" || isNumericUsername(username) || !base.UIDMatcher.MatchString(username) {
return errors.Errorf("invalid username %q", username)
if !base.ResourceIDMatcher.MatchString(username) {
return errors.New("invalid username: must be 1-63 characters, start with a lowercase letter, contain only lowercase letters, digits, or hyphens, and end with a letter or digit")
}
return nil
}
func isNumericUsername(username string) bool {
if username == "" {
return false
}
for _, char := range username {
if char < '0' || char > '9' {
return false
}
}
return true
}
// ResolveUserByName resolves a username-based user resource name to a store user.
func ResolveUserByName(ctx context.Context, stores *store.Store, name string) (*store.User, error) {
username, err := parseUsernameFromName(name)
@@ -1,6 +1,7 @@
package v1
import (
"strings"
"testing"
)
@@ -15,16 +16,36 @@ func TestValidateWritableUsername(t *testing.T) {
username: "alice",
},
{
name: "mixed case",
username: "Alice",
name: "mixed case",
username: "Alice",
wantError: true,
},
{
name: "hyphenated",
username: "alice-smith",
},
{
name: "uuid",
username: "550e8400-e29b-41d4-a716-446655440000",
name: "one character",
username: "a",
},
{
name: "maximum length",
username: "a" + strings.Repeat("b", 62),
},
{
name: "too long",
username: "a" + strings.Repeat("b", 63),
wantError: true,
},
{
name: "digit first",
username: "1alice",
wantError: true,
},
{
name: "hyphen last",
username: "alice-",
wantError: true,
},
{
name: "empty",
+5 -2
View File
@@ -187,8 +187,11 @@ func (s *APIV1Service) CreateUser(ctx context.Context, request *v1pb.CreateUserR
if request.User == nil {
return nil, status.Errorf(codes.InvalidArgument, "user is required")
}
if request.UserId != "" && request.UserId != request.User.Username {
return nil, status.Errorf(codes.InvalidArgument, "user_id must match user.username")
}
if err := validateWritableUsername(request.User.Username); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username)
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := validatePassword(request.User.Password); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
@@ -322,7 +325,7 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change username")
}
if err := validateWritableUsername(request.User.Username); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username)
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}
update.Username = &request.User.Username
case "display_name":
+5 -5
View File
@@ -92,7 +92,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, request *v1pb.ListA
}
userMemoStatMap := make(map[int32]*v1pb.UserStats)
pinnedMemoIDsByUserID := make(map[int32][]int32)
pinnedMemoUIDsByUserID := make(map[int32][]string)
limit := 1000
offset := 0
memoFind.Limit = &limit
@@ -156,7 +156,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, request *v1pb.ListA
// Track pinned memos
if memo.Pinned {
pinnedMemoIDsByUserID[memo.CreatorID] = append(pinnedMemoIDsByUserID[memo.CreatorID], memo.ID)
pinnedMemoUIDsByUserID[memo.CreatorID] = append(pinnedMemoUIDsByUserID[memo.CreatorID], memo.UID)
}
}
@@ -178,8 +178,8 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, request *v1pb.ListA
return nil, status.Errorf(codes.Internal, "failed to resolve user stats name")
}
userMemoStat.Name = fmt.Sprintf("%s/stats", BuildUserName(username))
for _, memoID := range pinnedMemoIDsByUserID[userID] {
userMemoStat.PinnedMemos = append(userMemoStat.PinnedMemos, fmt.Sprintf("%s/memos/%d", BuildUserName(username), memoID))
for _, memoUID := range pinnedMemoUIDsByUserID[userID] {
userMemoStat.PinnedMemos = append(userMemoStat.PinnedMemos, MemoNamePrefix+memoUID)
}
userMemoStats = append(userMemoStats, userMemoStat)
}
@@ -270,7 +270,7 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt
}
}
if memo.Pinned {
pinnedMemos = append(pinnedMemos, fmt.Sprintf("%s/memos/%d", BuildUserName(user.Username), memo.ID))
pinnedMemos = append(pinnedMemos, MemoNamePrefix+memo.UID)
}
}
@@ -16,7 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u;
// Helper function to extract shortcut ID from resource name
// Format: users/{username}/shortcuts/{shortcut}
// Format: users/{user}/shortcuts/{shortcut}
const getShortcutId = (name: string): string => {
const parts = name.split("/");
return parts.length === 4 ? parts[3] : "";
@@ -146,6 +146,7 @@ export type CreateAttachmentRequest = Message<"memos.api.v1.CreateAttachmentRequ
/**
* Optional. The attachment ID to use for this attachment.
* If empty, a unique ID will be generated.
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
*
* @generated from field: string attachment_id = 2;
*/
@@ -121,7 +121,7 @@ export const SignInRequest_PasswordCredentialsSchema: GenMessage<SignInRequest_P
export type SignInRequest_SSOCredentials = Message<"memos.api.v1.SignInRequest.SSOCredentials"> & {
/**
* The resource name of the SSO provider.
* Format: identity-providers/{uid}
* Format: identity-providers/{idp}
*
* @generated from field: string idp_name = 1;
*/
@@ -257,6 +257,7 @@ export type CreateIdentityProviderRequest = Message<"memos.api.v1.CreateIdentity
/**
* Optional. The ID to use for the identity provider, which will become the final component of the resource name.
* If not provided, the system will generate one.
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
*
* @generated from field: string identity_provider_id = 2;
*/
@@ -292,6 +292,7 @@ export type CreateMemoRequest = Message<"memos.api.v1.CreateMemoRequest"> & {
/**
* Optional. The memo ID to use for this memo.
* If empty, a unique ID will be generated.
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
*
* @generated from field: string memo_id = 2;
*/
@@ -16,7 +16,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file api/v1/shortcut_service.proto.
*/
export const file_api_v1_shortcut_service: GenFile = /*@__PURE__*/
fileDesc("Ch1hcGkvdjEvc2hvcnRjdXRfc2VydmljZS5wcm90bxIMbWVtb3MuYXBpLnYxIp4BCghTaG9ydGN1dBIRCgRuYW1lGAEgASgJQgPgQQgSEgoFdGl0bGUYAiABKAlCA+BBAhITCgZmaWx0ZXIYAyABKAlCA+BBATpW6kFTChVtZW1vcy5hcGkudjEvU2hvcnRjdXQSJXVzZXJzL3t1c2VybmFtZX0vc2hvcnRjdXRzL3tzaG9ydGN1dH0qCXNob3J0Y3V0czIIc2hvcnRjdXQiRQoUTGlzdFNob3J0Y3V0c1JlcXVlc3QSLQoGcGFyZW50GAEgASgJQh3gQQL6QRcSFW1lbW9zLmFwaS52MS9TaG9ydGN1dCJCChVMaXN0U2hvcnRjdXRzUmVzcG9uc2USKQoJc2hvcnRjdXRzGAEgAygLMhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0IkEKEkdldFNob3J0Y3V0UmVxdWVzdBIrCgRuYW1lGAEgASgJQh3gQQL6QRcKFW1lbW9zLmFwaS52MS9TaG9ydGN1dCKRAQoVQ3JlYXRlU2hvcnRjdXRSZXF1ZXN0Ei0KBnBhcmVudBgBIAEoCUId4EEC+kEXEhVtZW1vcy5hcGkudjEvU2hvcnRjdXQSLQoIc2hvcnRjdXQYAiABKAsyFi5tZW1vcy5hcGkudjEuU2hvcnRjdXRCA+BBAhIaCg12YWxpZGF0ZV9vbmx5GAMgASgIQgPgQQEifAoVVXBkYXRlU2hvcnRjdXRSZXF1ZXN0Ei0KCHNob3J0Y3V0GAEgASgLMhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0QgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQEiRAoVRGVsZXRlU2hvcnRjdXRSZXF1ZXN0EisKBG5hbWUYASABKAlCHeBBAvpBFwoVbWVtb3MuYXBpLnYxL1Nob3J0Y3V0Mt4FCg9TaG9ydGN1dFNlcnZpY2USjQEKDUxpc3RTaG9ydGN1dHMSIi5tZW1vcy5hcGkudjEuTGlzdFNob3J0Y3V0c1JlcXVlc3QaIy5tZW1vcy5hcGkudjEuTGlzdFNob3J0Y3V0c1Jlc3BvbnNlIjPaQQZwYXJlbnSC0+STAiQSIi9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9zaG9ydGN1dHMSegoLR2V0U2hvcnRjdXQSIC5tZW1vcy5hcGkudjEuR2V0U2hvcnRjdXRSZXF1ZXN0GhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0IjHaQQRuYW1lgtPkkwIkEiIvYXBpL3YxL3tuYW1lPXVzZXJzLyovc2hvcnRjdXRzLyp9EpUBCg5DcmVhdGVTaG9ydGN1dBIjLm1lbW9zLmFwaS52MS5DcmVhdGVTaG9ydGN1dFJlcXVlc3QaFi5tZW1vcy5hcGkudjEuU2hvcnRjdXQiRtpBD3BhcmVudCxzaG9ydGN1dILT5JMCLjoIc2hvcnRjdXQiIi9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9zaG9ydGN1dHMSowEKDlVwZGF0ZVNob3J0Y3V0EiMubWVtb3MuYXBpLnYxLlVwZGF0ZVNob3J0Y3V0UmVxdWVzdBoWLm1lbW9zLmFwaS52MS5TaG9ydGN1dCJU2kEUc2hvcnRjdXQsdXBkYXRlX21hc2uC0+STAjc6CHNob3J0Y3V0MisvYXBpL3YxL3tzaG9ydGN1dC5uYW1lPXVzZXJzLyovc2hvcnRjdXRzLyp9EoABCg5EZWxldGVTaG9ydGN1dBIjLm1lbW9zLmFwaS52MS5EZWxldGVTaG9ydGN1dFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiMdpBBG5hbWWC0+STAiQqIi9hcGkvdjEve25hbWU9dXNlcnMvKi9zaG9ydGN1dHMvKn1CrAEKEGNvbS5tZW1vcy5hcGkudjFCFFNob3J0Y3V0U2VydmljZVByb3RvUAFaMGdpdGh1Yi5jb20vdXNlbWVtb3MvbWVtb3MvcHJvdG8vZ2VuL2FwaS92MTthcGl2MaICA01BWKoCDE1lbW9zLkFwaS5WMcoCDE1lbW9zXEFwaVxWMeICGE1lbW9zXEFwaVxWMVxHUEJNZXRhZGF0YeoCDk1lbW9zOjpBcGk6OlYxYgZwcm90bzM", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask]);
fileDesc("Ch1hcGkvdjEvc2hvcnRjdXRfc2VydmljZS5wcm90bxIMbWVtb3MuYXBpLnYxIpoBCghTaG9ydGN1dBIRCgRuYW1lGAEgASgJQgPgQQgSEgoFdGl0bGUYAiABKAlCA+BBAhITCgZmaWx0ZXIYAyABKAlCA+BBATpS6kFPChVtZW1vcy5hcGkudjEvU2hvcnRjdXQSIXVzZXJzL3t1c2VyfS9zaG9ydGN1dHMve3Nob3J0Y3V0fSoJc2hvcnRjdXRzMghzaG9ydGN1dCJFChRMaXN0U2hvcnRjdXRzUmVxdWVzdBItCgZwYXJlbnQYASABKAlCHeBBAvpBFxIVbWVtb3MuYXBpLnYxL1Nob3J0Y3V0IkIKFUxpc3RTaG9ydGN1dHNSZXNwb25zZRIpCglzaG9ydGN1dHMYASADKAsyFi5tZW1vcy5hcGkudjEuU2hvcnRjdXQiQQoSR2V0U2hvcnRjdXRSZXF1ZXN0EisKBG5hbWUYASABKAlCHeBBAvpBFwoVbWVtb3MuYXBpLnYxL1Nob3J0Y3V0IpEBChVDcmVhdGVTaG9ydGN1dFJlcXVlc3QSLQoGcGFyZW50GAEgASgJQh3gQQL6QRcSFW1lbW9zLmFwaS52MS9TaG9ydGN1dBItCghzaG9ydGN1dBgCIAEoCzIWLm1lbW9zLmFwaS52MS5TaG9ydGN1dEID4EECEhoKDXZhbGlkYXRlX29ubHkYAyABKAhCA+BBASJ8ChVVcGRhdGVTaG9ydGN1dFJlcXVlc3QSLQoIc2hvcnRjdXQYASABKAsyFi5tZW1vcy5hcGkudjEuU2hvcnRjdXRCA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBASJEChVEZWxldGVTaG9ydGN1dFJlcXVlc3QSKwoEbmFtZRgBIAEoCUId4EEC+kEXChVtZW1vcy5hcGkudjEvU2hvcnRjdXQy3gUKD1Nob3J0Y3V0U2VydmljZRKNAQoNTGlzdFNob3J0Y3V0cxIiLm1lbW9zLmFwaS52MS5MaXN0U2hvcnRjdXRzUmVxdWVzdBojLm1lbW9zLmFwaS52MS5MaXN0U2hvcnRjdXRzUmVzcG9uc2UiM9pBBnBhcmVudILT5JMCJBIiL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3Nob3J0Y3V0cxJ6CgtHZXRTaG9ydGN1dBIgLm1lbW9zLmFwaS52MS5HZXRTaG9ydGN1dFJlcXVlc3QaFi5tZW1vcy5hcGkudjEuU2hvcnRjdXQiMdpBBG5hbWWC0+STAiQSIi9hcGkvdjEve25hbWU9dXNlcnMvKi9zaG9ydGN1dHMvKn0SlQEKDkNyZWF0ZVNob3J0Y3V0EiMubWVtb3MuYXBpLnYxLkNyZWF0ZVNob3J0Y3V0UmVxdWVzdBoWLm1lbW9zLmFwaS52MS5TaG9ydGN1dCJG2kEPcGFyZW50LHNob3J0Y3V0gtPkkwIuOghzaG9ydGN1dCIiL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3Nob3J0Y3V0cxKjAQoOVXBkYXRlU2hvcnRjdXQSIy5tZW1vcy5hcGkudjEuVXBkYXRlU2hvcnRjdXRSZXF1ZXN0GhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0IlTaQRRzaG9ydGN1dCx1cGRhdGVfbWFza4LT5JMCNzoIc2hvcnRjdXQyKy9hcGkvdjEve3Nob3J0Y3V0Lm5hbWU9dXNlcnMvKi9zaG9ydGN1dHMvKn0SgAEKDkRlbGV0ZVNob3J0Y3V0EiMubWVtb3MuYXBpLnYxLkRlbGV0ZVNob3J0Y3V0UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIx2kEEbmFtZYLT5JMCJCoiL2FwaS92MS97bmFtZT11c2Vycy8qL3Nob3J0Y3V0cy8qfUKsAQoQY29tLm1lbW9zLmFwaS52MUIUU2hvcnRjdXRTZXJ2aWNlUHJvdG9QAVowZ2l0aHViLmNvbS91c2VtZW1vcy9tZW1vcy9wcm90by9nZW4vYXBpL3YxO2FwaXYxogIDTUFYqgIMTWVtb3MuQXBpLlYxygIMTWVtb3NcQXBpXFYx4gIYTWVtb3NcQXBpXFYxXEdQQk1ldGFkYXRh6gIOTWVtb3M6OkFwaTo6VjFiBnByb3RvMw", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask]);
/**
* @generated from message memos.api.v1.Shortcut
@@ -24,7 +24,7 @@ export const file_api_v1_shortcut_service: GenFile = /*@__PURE__*/
export type Shortcut = Message<"memos.api.v1.Shortcut"> & {
/**
* The resource name of the shortcut.
* Format: users/{username}/shortcuts/{shortcut}
* Format: users/{user}/shortcuts/{shortcut}
*
* @generated from field: string name = 1;
*/
@@ -59,7 +59,7 @@ export const ShortcutSchema: GenMessage<Shortcut> = /*@__PURE__*/
export type ListShortcutsRequest = Message<"memos.api.v1.ListShortcutsRequest"> & {
/**
* Required. The parent resource where shortcuts are listed.
* Format: users/{username}
* Format: users/{user}
*
* @generated from field: string parent = 1;
*/
@@ -98,7 +98,7 @@ export const ListShortcutsResponseSchema: GenMessage<ListShortcutsResponse> = /*
export type GetShortcutRequest = Message<"memos.api.v1.GetShortcutRequest"> & {
/**
* Required. The resource name of the shortcut to retrieve.
* Format: users/{username}/shortcuts/{shortcut}
* Format: users/{user}/shortcuts/{shortcut}
*
* @generated from field: string name = 1;
*/
@@ -118,7 +118,7 @@ export const GetShortcutRequestSchema: GenMessage<GetShortcutRequest> = /*@__PUR
export type CreateShortcutRequest = Message<"memos.api.v1.CreateShortcutRequest"> & {
/**
* Required. The parent resource where this shortcut will be created.
* Format: users/{username}
* Format: users/{user}
*
* @generated from field: string parent = 1;
*/
@@ -178,7 +178,7 @@ export const UpdateShortcutRequestSchema: GenMessage<UpdateShortcutRequest> = /*
export type DeleteShortcutRequest = Message<"memos.api.v1.DeleteShortcutRequest"> & {
/**
* Required. The resource name of the shortcut to delete.
* Format: users/{username}/shortcuts/{shortcut}
* Format: users/{user}/shortcuts/{shortcut}
*
* @generated from field: string name = 1;
*/
File diff suppressed because one or more lines are too long
+5 -7
View File
@@ -10,17 +10,15 @@
*/
export const MENTION_CHAR_CLASS = "[A-Za-z0-9-]";
export const MAX_MENTION_LENGTH = 32;
export const MAX_MENTION_LENGTH = 63;
/**
* Regex source for a username run: 1..MAX_MENTION_LENGTH mention characters that
* include at least one letter or digit. Unlike TAG_RUN this does not refuse an
* over-long run a 33rd mention character simply falls outside the capture and
* becomes ordinary text, mirroring the read-only parser's truncation. The
* leading lookahead enforces the "must contain an alphanumeric" rule so `@---`
* is left as plain text. Embed after an `@`, e.g. `^@(${MENTION_RUN})`.
* include at least one letter or digit. An over-long run is left as plain text.
* The leading lookahead enforces the "must contain an alphanumeric" rule so
* `@---` is left as plain text. Embed after an `@`, e.g. `^@(${MENTION_RUN})`.
*/
export const MENTION_RUN = `(?=${MENTION_CHAR_CLASS}{0,${MAX_MENTION_LENGTH - 1}}[A-Za-z0-9])${MENTION_CHAR_CLASS}{1,${MAX_MENTION_LENGTH}}`;
export const MENTION_RUN = `(?=${MENTION_CHAR_CLASS}{0,${MAX_MENTION_LENGTH - 1}}[A-Za-z0-9])${MENTION_CHAR_CLASS}{1,${MAX_MENTION_LENGTH}}(?!${MENTION_CHAR_CLASS})`;
const SINGLE_MENTION_CHAR = /^[A-Za-z0-9-]$/;
@@ -25,8 +25,9 @@ export function parseMentionsFromText(text: string): Segment[] {
}
const username = chars.slice(i + 1, j).join("");
const isOverlong = j < chars.length && isMentionChar(chars[j]);
const hasLetterOrNumber = [...username].some((char) => /[A-Za-z0-9]/.test(char));
if (username && hasLetterOrNumber) {
if (username && !isOverlong && hasLetterOrNumber) {
segments.push({ type: "mention", value: username.toLowerCase() });
i = j;
continue;
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { parseMentionsFromText } from "@/utils/remark-plugins/remark-mention";
describe("parseMentionsFromText", () => {
it("accepts a 63-character username", () => {
const username = `a${"b".repeat(62)}`;
expect(parseMentionsFromText(`@${username}`)).toEqual([{ type: "mention", value: username }]);
});
it("leaves a 64-character username as text", () => {
const username = `a${"b".repeat(63)}`;
expect(parseMentionsFromText(`@${username}`)).toEqual([{ type: "text", value: `@${username}` }]);
});
});