diff --git a/proto/api/v1/user_service.proto b/proto/api/v1/user_service.proto index 9e98ed56b..c43bba6b6 100644 --- a/proto/api/v1/user_service.proto +++ b/proto/api/v1/user_service.proto @@ -108,6 +108,18 @@ service UserService { option (google.api.http) = {delete: "/api/v1/{name=users/*/accessTokens/*}"}; option (google.api.method_signature) = "name"; } + + // ListUserSessions returns a list of active sessions for a user. + rpc ListUserSessions(ListUserSessionsRequest) returns (ListUserSessionsResponse) { + option (google.api.http) = {get: "/api/v1/{parent=users/*}/sessions"}; + option (google.api.method_signature) = "parent"; + } + + // RevokeUserSession revokes a specific session for a user. + rpc RevokeUserSession(RevokeUserSessionRequest) returns (google.protobuf.Empty) { + option (google.api.http) = {delete: "/api/v1/{name=users/*/sessions/*}"}; + option (google.api.method_signature) = "name"; + } } message User { @@ -458,6 +470,76 @@ message DeleteUserAccessTokenRequest { ]; } +message UserSession { + option (google.api.resource) = { + type: "memos.api.v1/UserSession" + pattern: "users/{user}/sessions/{session}" + name_field: "name" + }; + + // The resource name of the session. + // Format: users/{user}/sessions/{session} + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // The session ID. + string session_id = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // The timestamp when the session was created. + google.protobuf.Timestamp create_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // The timestamp when the session expires. + google.protobuf.Timestamp expire_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // The timestamp when the session was last accessed. + google.protobuf.Timestamp last_accessed_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Client information associated with this session. + ClientInfo client_info = 6 [(google.api.field_behavior) = OUTPUT_ONLY]; + + message ClientInfo { + // User agent string of the client. + string user_agent = 1; + + // IP address of the client. + string ip_address = 2; + + // Optional. Device type (e.g., "mobile", "desktop", "tablet"). + string device_type = 3 [(google.api.field_behavior) = OPTIONAL]; + + // Optional. Operating system (e.g., "iOS 17.0", "Windows 11"). + string os = 4 [(google.api.field_behavior) = OPTIONAL]; + + // Optional. Browser name and version (e.g., "Chrome 119.0"). + string browser = 5 [(google.api.field_behavior) = OPTIONAL]; + + // Optional. Geographic location (country code, e.g., "US"). + string country = 6 [(google.api.field_behavior) = OPTIONAL]; + } +} + +message ListUserSessionsRequest { + // Required. The resource name of the parent. + // Format: users/{user} + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "memos.api.v1/User"} + ]; +} + +message ListUserSessionsResponse { + // The list of user sessions. + repeated UserSession sessions = 1; +} + +message RevokeUserSessionRequest { + // Required. The resource name of the session to revoke. + // Format: users/{user}/sessions/{session} + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "memos.api.v1/UserSession"} + ]; +} + message ListAllUserStatsRequest { // Optional. The maximum number of user stats to return. int32 page_size = 1 [(google.api.field_behavior) = OPTIONAL]; diff --git a/proto/gen/api/v1/user_service.pb.go b/proto/gen/api/v1/user_service.pb.go index 673a4c0b3..7b9782254 100644 --- a/proto/gen/api/v1/user_service.pb.go +++ b/proto/gen/api/v1/user_service.pb.go @@ -1443,6 +1443,234 @@ func (x *DeleteUserAccessTokenRequest) GetName() string { return "" } +type UserSession struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The resource name of the session. + // Format: users/{user}/sessions/{session} + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The session ID. + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The timestamp when the session was created. + CreateTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` + // The timestamp when the session expires. + ExpireTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` + // The timestamp when the session was last accessed. + LastAccessedTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=last_accessed_time,json=lastAccessedTime,proto3" json:"last_accessed_time,omitempty"` + // Client information associated with this session. + ClientInfo *UserSession_ClientInfo `protobuf:"bytes,6,opt,name=client_info,json=clientInfo,proto3" json:"client_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserSession) Reset() { + *x = UserSession{} + mi := &file_api_v1_user_service_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserSession) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserSession) ProtoMessage() {} + +func (x *UserSession) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_user_service_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserSession.ProtoReflect.Descriptor instead. +func (*UserSession) Descriptor() ([]byte, []int) { + return file_api_v1_user_service_proto_rawDescGZIP(), []int{20} +} + +func (x *UserSession) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *UserSession) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *UserSession) GetCreateTime() *timestamppb.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *UserSession) GetExpireTime() *timestamppb.Timestamp { + if x != nil { + return x.ExpireTime + } + return nil +} + +func (x *UserSession) GetLastAccessedTime() *timestamppb.Timestamp { + if x != nil { + return x.LastAccessedTime + } + return nil +} + +func (x *UserSession) GetClientInfo() *UserSession_ClientInfo { + if x != nil { + return x.ClientInfo + } + return nil +} + +type ListUserSessionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. The resource name of the parent. + // Format: users/{user} + Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListUserSessionsRequest) Reset() { + *x = ListUserSessionsRequest{} + mi := &file_api_v1_user_service_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListUserSessionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListUserSessionsRequest) ProtoMessage() {} + +func (x *ListUserSessionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_user_service_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListUserSessionsRequest.ProtoReflect.Descriptor instead. +func (*ListUserSessionsRequest) Descriptor() ([]byte, []int) { + return file_api_v1_user_service_proto_rawDescGZIP(), []int{21} +} + +func (x *ListUserSessionsRequest) GetParent() string { + if x != nil { + return x.Parent + } + return "" +} + +type ListUserSessionsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The list of user sessions. + Sessions []*UserSession `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListUserSessionsResponse) Reset() { + *x = ListUserSessionsResponse{} + mi := &file_api_v1_user_service_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListUserSessionsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListUserSessionsResponse) ProtoMessage() {} + +func (x *ListUserSessionsResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_user_service_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListUserSessionsResponse.ProtoReflect.Descriptor instead. +func (*ListUserSessionsResponse) Descriptor() ([]byte, []int) { + return file_api_v1_user_service_proto_rawDescGZIP(), []int{22} +} + +func (x *ListUserSessionsResponse) GetSessions() []*UserSession { + if x != nil { + return x.Sessions + } + return nil +} + +type RevokeUserSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. The resource name of the session to revoke. + // Format: users/{user}/sessions/{session} + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeUserSessionRequest) Reset() { + *x = RevokeUserSessionRequest{} + mi := &file_api_v1_user_service_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeUserSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeUserSessionRequest) ProtoMessage() {} + +func (x *RevokeUserSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_user_service_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeUserSessionRequest.ProtoReflect.Descriptor instead. +func (*RevokeUserSessionRequest) Descriptor() ([]byte, []int) { + return file_api_v1_user_service_proto_rawDescGZIP(), []int{23} +} + +func (x *RevokeUserSessionRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + type ListAllUserStatsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Optional. The maximum number of user stats to return. @@ -1455,7 +1683,7 @@ type ListAllUserStatsRequest struct { func (x *ListAllUserStatsRequest) Reset() { *x = ListAllUserStatsRequest{} - mi := &file_api_v1_user_service_proto_msgTypes[20] + mi := &file_api_v1_user_service_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1467,7 +1695,7 @@ func (x *ListAllUserStatsRequest) String() string { func (*ListAllUserStatsRequest) ProtoMessage() {} func (x *ListAllUserStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_user_service_proto_msgTypes[20] + mi := &file_api_v1_user_service_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1480,7 +1708,7 @@ func (x *ListAllUserStatsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListAllUserStatsRequest.ProtoReflect.Descriptor instead. func (*ListAllUserStatsRequest) Descriptor() ([]byte, []int) { - return file_api_v1_user_service_proto_rawDescGZIP(), []int{20} + return file_api_v1_user_service_proto_rawDescGZIP(), []int{24} } func (x *ListAllUserStatsRequest) GetPageSize() int32 { @@ -1511,7 +1739,7 @@ type ListAllUserStatsResponse struct { func (x *ListAllUserStatsResponse) Reset() { *x = ListAllUserStatsResponse{} - mi := &file_api_v1_user_service_proto_msgTypes[21] + mi := &file_api_v1_user_service_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1523,7 +1751,7 @@ func (x *ListAllUserStatsResponse) String() string { func (*ListAllUserStatsResponse) ProtoMessage() {} func (x *ListAllUserStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_user_service_proto_msgTypes[21] + mi := &file_api_v1_user_service_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1536,7 +1764,7 @@ func (x *ListAllUserStatsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListAllUserStatsResponse.ProtoReflect.Descriptor instead. func (*ListAllUserStatsResponse) Descriptor() ([]byte, []int) { - return file_api_v1_user_service_proto_rawDescGZIP(), []int{21} + return file_api_v1_user_service_proto_rawDescGZIP(), []int{25} } func (x *ListAllUserStatsResponse) GetUserStats() []*UserStats { @@ -1573,7 +1801,7 @@ type UserStats_MemoTypeStats struct { func (x *UserStats_MemoTypeStats) Reset() { *x = UserStats_MemoTypeStats{} - mi := &file_api_v1_user_service_proto_msgTypes[23] + mi := &file_api_v1_user_service_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1585,7 +1813,7 @@ func (x *UserStats_MemoTypeStats) String() string { func (*UserStats_MemoTypeStats) ProtoMessage() {} func (x *UserStats_MemoTypeStats) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_user_service_proto_msgTypes[23] + mi := &file_api_v1_user_service_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1629,6 +1857,96 @@ func (x *UserStats_MemoTypeStats) GetUndoCount() int32 { return 0 } +type UserSession_ClientInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + // User agent string of the client. + UserAgent string `protobuf:"bytes,1,opt,name=user_agent,json=userAgent,proto3" json:"user_agent,omitempty"` + // IP address of the client. + IpAddress string `protobuf:"bytes,2,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"` + // Optional. Device type (e.g., "mobile", "desktop", "tablet"). + DeviceType string `protobuf:"bytes,3,opt,name=device_type,json=deviceType,proto3" json:"device_type,omitempty"` + // Optional. Operating system (e.g., "iOS 17.0", "Windows 11"). + Os string `protobuf:"bytes,4,opt,name=os,proto3" json:"os,omitempty"` + // Optional. Browser name and version (e.g., "Chrome 119.0"). + Browser string `protobuf:"bytes,5,opt,name=browser,proto3" json:"browser,omitempty"` + // Optional. Geographic location (country code, e.g., "US"). + Country string `protobuf:"bytes,6,opt,name=country,proto3" json:"country,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserSession_ClientInfo) Reset() { + *x = UserSession_ClientInfo{} + mi := &file_api_v1_user_service_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserSession_ClientInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserSession_ClientInfo) ProtoMessage() {} + +func (x *UserSession_ClientInfo) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_user_service_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserSession_ClientInfo.ProtoReflect.Descriptor instead. +func (*UserSession_ClientInfo) Descriptor() ([]byte, []int) { + return file_api_v1_user_service_proto_rawDescGZIP(), []int{20, 0} +} + +func (x *UserSession_ClientInfo) GetUserAgent() string { + if x != nil { + return x.UserAgent + } + return "" +} + +func (x *UserSession_ClientInfo) GetIpAddress() string { + if x != nil { + return x.IpAddress + } + return "" +} + +func (x *UserSession_ClientInfo) GetDeviceType() string { + if x != nil { + return x.DeviceType + } + return "" +} + +func (x *UserSession_ClientInfo) GetOs() string { + if x != nil { + return x.Os + } + return "" +} + +func (x *UserSession_ClientInfo) GetBrowser() string { + if x != nil { + return x.Browser + } + return "" +} + +func (x *UserSession_ClientInfo) GetCountry() string { + if x != nil { + return x.Country + } + return "" +} + var File_api_v1_user_service_proto protoreflect.FileDescriptor const file_api_v1_user_service_proto_rawDesc = "" + @@ -1766,7 +2084,38 @@ const file_api_v1_user_service_proto_rawDesc = "" + "\x0faccess_token_id\x18\x03 \x01(\tB\x03\xe0A\x01R\raccessTokenId\"X\n" + "\x1cDeleteUserAccessTokenRequest\x128\n" + "\x04name\x18\x01 \x01(\tB$\xe0A\x02\xfaA\x1e\n" + - "\x1cmemos.api.v1/UserAccessTokenR\x04name\"_\n" + + "\x1cmemos.api.v1/UserAccessTokenR\x04name\"\xf5\x04\n" + + "\vUserSession\x12\x17\n" + + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\"\n" + + "\n" + + "session_id\x18\x02 \x01(\tB\x03\xe0A\x03R\tsessionId\x12@\n" + + "\vcreate_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" + + "createTime\x12@\n" + + "\vexpire_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" + + "expireTime\x12M\n" + + "\x12last_accessed_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\x10lastAccessedTime\x12J\n" + + "\vclient_info\x18\x06 \x01(\v2$.memos.api.v1.UserSession.ClientInfoB\x03\xe0A\x03R\n" + + "clientInfo\x1a\xc3\x01\n" + + "\n" + + "ClientInfo\x12\x1d\n" + + "\n" + + "user_agent\x18\x01 \x01(\tR\tuserAgent\x12\x1d\n" + + "\n" + + "ip_address\x18\x02 \x01(\tR\tipAddress\x12$\n" + + "\vdevice_type\x18\x03 \x01(\tB\x03\xe0A\x01R\n" + + "deviceType\x12\x13\n" + + "\x02os\x18\x04 \x01(\tB\x03\xe0A\x01R\x02os\x12\x1d\n" + + "\abrowser\x18\x05 \x01(\tB\x03\xe0A\x01R\abrowser\x12\x1d\n" + + "\acountry\x18\x06 \x01(\tB\x03\xe0A\x01R\acountry:D\xeaAA\n" + + "\x18memos.api.v1/UserSession\x12\x1fusers/{user}/sessions/{session}\x1a\x04name\"L\n" + + "\x17ListUserSessionsRequest\x121\n" + + "\x06parent\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" + + "\x11memos.api.v1/UserR\x06parent\"Q\n" + + "\x18ListUserSessionsResponse\x125\n" + + "\bsessions\x18\x01 \x03(\v2\x19.memos.api.v1.UserSessionR\bsessions\"P\n" + + "\x18RevokeUserSessionRequest\x124\n" + + "\x04name\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\n" + + "\x18memos.api.v1/UserSessionR\x04name\"_\n" + "\x17ListAllUserStatsRequest\x12 \n" + "\tpage_size\x18\x01 \x01(\x05B\x03\xe0A\x01R\bpageSize\x12\"\n" + "\n" + @@ -1776,7 +2125,7 @@ const file_api_v1_user_service_proto_rawDesc = "" + "user_stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\tuserStats\x12&\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1d\n" + "\n" + - "total_size\x18\x03 \x01(\x05R\ttotalSize2\xc2\x0e\n" + + "total_size\x18\x03 \x01(\x05R\ttotalSize2\xe2\x10\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\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" + @@ -1794,7 +2143,9 @@ const file_api_v1_user_service_proto_rawDesc = "" + "\x11UpdateUserSetting\x12&.memos.api.v1.UpdateUserSettingRequest\x1a\x19.memos.api.v1.UserSetting\"S\xdaA\x13setting,update_mask\x82\xd3\xe4\x93\x027:\asetting2,/api/v1/{setting.name=users/*}:updateSetting\x12\xa5\x01\n" + "\x14ListUserAccessTokens\x12).memos.api.v1.ListUserAccessTokensRequest\x1a*.memos.api.v1.ListUserAccessTokensResponse\"6\xdaA\x06parent\x82\xd3\xe4\x93\x02'\x12%/api/v1/{parent=users/*}/accessTokens\x12\xb5\x01\n" + "\x15CreateUserAccessToken\x12*.memos.api.v1.CreateUserAccessTokenRequest\x1a\x1d.memos.api.v1.UserAccessToken\"Q\xdaA\x13parent,access_token\x82\xd3\xe4\x93\x025:\faccess_token\"%/api/v1/{parent=users/*}/accessTokens\x12\x91\x01\n" + - "\x15DeleteUserAccessToken\x12*.memos.api.v1.DeleteUserAccessTokenRequest\x1a\x16.google.protobuf.Empty\"4\xdaA\x04name\x82\xd3\xe4\x93\x02'*%/api/v1/{name=users/*/accessTokens/*}B\xa8\x01\n" + + "\x15DeleteUserAccessToken\x12*.memos.api.v1.DeleteUserAccessTokenRequest\x1a\x16.google.protobuf.Empty\"4\xdaA\x04name\x82\xd3\xe4\x93\x02'*%/api/v1/{name=users/*/accessTokens/*}\x12\x95\x01\n" + + "\x10ListUserSessions\x12%.memos.api.v1.ListUserSessionsRequest\x1a&.memos.api.v1.ListUserSessionsResponse\"2\xdaA\x06parent\x82\xd3\xe4\x93\x02#\x12!/api/v1/{parent=users/*}/sessions\x12\x85\x01\n" + + "\x11RevokeUserSession\x12&.memos.api.v1.RevokeUserSessionRequest\x1a\x16.google.protobuf.Empty\"0\xdaA\x04name\x82\xd3\xe4\x93\x02#*!/api/v1/{name=users/*/sessions/*}B\xa8\x01\n" + "\x10com.memos.api.v1B\x10UserServiceProtoP\x01Z0github.com/usememos/memos/proto/gen/api/v1;apiv1\xa2\x02\x03MAX\xaa\x02\fMemos.Api.V1\xca\x02\fMemos\\Api\\V1\xe2\x02\x18Memos\\Api\\V1\\GPBMetadata\xea\x02\x0eMemos::Api::V1b\x06proto3" var ( @@ -1810,7 +2161,7 @@ func file_api_v1_user_service_proto_rawDescGZIP() []byte { } var file_api_v1_user_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_api_v1_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_api_v1_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 29) var file_api_v1_user_service_proto_goTypes = []any{ (User_Role)(0), // 0: memos.api.v1.User.Role (*User)(nil), // 1: memos.api.v1.User @@ -1833,70 +2184,84 @@ var file_api_v1_user_service_proto_goTypes = []any{ (*ListUserAccessTokensResponse)(nil), // 18: memos.api.v1.ListUserAccessTokensResponse (*CreateUserAccessTokenRequest)(nil), // 19: memos.api.v1.CreateUserAccessTokenRequest (*DeleteUserAccessTokenRequest)(nil), // 20: memos.api.v1.DeleteUserAccessTokenRequest - (*ListAllUserStatsRequest)(nil), // 21: memos.api.v1.ListAllUserStatsRequest - (*ListAllUserStatsResponse)(nil), // 22: memos.api.v1.ListAllUserStatsResponse - nil, // 23: memos.api.v1.UserStats.TagCountEntry - (*UserStats_MemoTypeStats)(nil), // 24: memos.api.v1.UserStats.MemoTypeStats - (State)(0), // 25: memos.api.v1.State - (*timestamppb.Timestamp)(nil), // 26: google.protobuf.Timestamp - (*fieldmaskpb.FieldMask)(nil), // 27: google.protobuf.FieldMask - (*emptypb.Empty)(nil), // 28: google.protobuf.Empty - (*httpbody.HttpBody)(nil), // 29: google.api.HttpBody + (*UserSession)(nil), // 21: memos.api.v1.UserSession + (*ListUserSessionsRequest)(nil), // 22: memos.api.v1.ListUserSessionsRequest + (*ListUserSessionsResponse)(nil), // 23: memos.api.v1.ListUserSessionsResponse + (*RevokeUserSessionRequest)(nil), // 24: memos.api.v1.RevokeUserSessionRequest + (*ListAllUserStatsRequest)(nil), // 25: memos.api.v1.ListAllUserStatsRequest + (*ListAllUserStatsResponse)(nil), // 26: memos.api.v1.ListAllUserStatsResponse + nil, // 27: memos.api.v1.UserStats.TagCountEntry + (*UserStats_MemoTypeStats)(nil), // 28: memos.api.v1.UserStats.MemoTypeStats + (*UserSession_ClientInfo)(nil), // 29: memos.api.v1.UserSession.ClientInfo + (State)(0), // 30: memos.api.v1.State + (*timestamppb.Timestamp)(nil), // 31: google.protobuf.Timestamp + (*fieldmaskpb.FieldMask)(nil), // 32: google.protobuf.FieldMask + (*emptypb.Empty)(nil), // 33: google.protobuf.Empty + (*httpbody.HttpBody)(nil), // 34: google.api.HttpBody } var file_api_v1_user_service_proto_depIdxs = []int32{ 0, // 0: memos.api.v1.User.role:type_name -> memos.api.v1.User.Role - 25, // 1: memos.api.v1.User.state:type_name -> memos.api.v1.State - 26, // 2: memos.api.v1.User.create_time:type_name -> google.protobuf.Timestamp - 26, // 3: memos.api.v1.User.update_time:type_name -> google.protobuf.Timestamp + 30, // 1: memos.api.v1.User.state:type_name -> memos.api.v1.State + 31, // 2: memos.api.v1.User.create_time:type_name -> google.protobuf.Timestamp + 31, // 3: memos.api.v1.User.update_time:type_name -> google.protobuf.Timestamp 1, // 4: memos.api.v1.ListUsersResponse.users:type_name -> memos.api.v1.User - 27, // 5: memos.api.v1.GetUserRequest.read_mask:type_name -> google.protobuf.FieldMask + 32, // 5: memos.api.v1.GetUserRequest.read_mask:type_name -> google.protobuf.FieldMask 1, // 6: memos.api.v1.CreateUserRequest.user:type_name -> memos.api.v1.User 1, // 7: memos.api.v1.UpdateUserRequest.user:type_name -> memos.api.v1.User - 27, // 8: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask + 32, // 8: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask 1, // 9: memos.api.v1.SearchUsersResponse.users:type_name -> memos.api.v1.User - 26, // 10: memos.api.v1.UserStats.memo_display_timestamps:type_name -> google.protobuf.Timestamp - 24, // 11: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats - 23, // 12: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry + 31, // 10: memos.api.v1.UserStats.memo_display_timestamps:type_name -> google.protobuf.Timestamp + 28, // 11: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats + 27, // 12: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry 13, // 13: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting - 27, // 14: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask - 26, // 15: memos.api.v1.UserAccessToken.issued_at:type_name -> google.protobuf.Timestamp - 26, // 16: memos.api.v1.UserAccessToken.expires_at:type_name -> google.protobuf.Timestamp + 32, // 14: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask + 31, // 15: memos.api.v1.UserAccessToken.issued_at:type_name -> google.protobuf.Timestamp + 31, // 16: memos.api.v1.UserAccessToken.expires_at:type_name -> google.protobuf.Timestamp 16, // 17: memos.api.v1.ListUserAccessTokensResponse.access_tokens:type_name -> memos.api.v1.UserAccessToken 16, // 18: memos.api.v1.CreateUserAccessTokenRequest.access_token:type_name -> memos.api.v1.UserAccessToken - 11, // 19: memos.api.v1.ListAllUserStatsResponse.user_stats:type_name -> memos.api.v1.UserStats - 2, // 20: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest - 4, // 21: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest - 5, // 22: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest - 6, // 23: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest - 7, // 24: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest - 8, // 25: memos.api.v1.UserService.SearchUsers:input_type -> memos.api.v1.SearchUsersRequest - 10, // 26: memos.api.v1.UserService.GetUserAvatar:input_type -> memos.api.v1.GetUserAvatarRequest - 21, // 27: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest - 12, // 28: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest - 14, // 29: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest - 15, // 30: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest - 17, // 31: memos.api.v1.UserService.ListUserAccessTokens:input_type -> memos.api.v1.ListUserAccessTokensRequest - 19, // 32: memos.api.v1.UserService.CreateUserAccessToken:input_type -> memos.api.v1.CreateUserAccessTokenRequest - 20, // 33: memos.api.v1.UserService.DeleteUserAccessToken:input_type -> memos.api.v1.DeleteUserAccessTokenRequest - 3, // 34: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse - 1, // 35: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User - 1, // 36: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User - 1, // 37: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User - 28, // 38: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty - 9, // 39: memos.api.v1.UserService.SearchUsers:output_type -> memos.api.v1.SearchUsersResponse - 29, // 40: memos.api.v1.UserService.GetUserAvatar:output_type -> google.api.HttpBody - 22, // 41: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse - 11, // 42: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats - 13, // 43: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting - 13, // 44: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting - 18, // 45: memos.api.v1.UserService.ListUserAccessTokens:output_type -> memos.api.v1.ListUserAccessTokensResponse - 16, // 46: memos.api.v1.UserService.CreateUserAccessToken:output_type -> memos.api.v1.UserAccessToken - 28, // 47: memos.api.v1.UserService.DeleteUserAccessToken:output_type -> google.protobuf.Empty - 34, // [34:48] is the sub-list for method output_type - 20, // [20:34] is the sub-list for method input_type - 20, // [20:20] is the sub-list for extension type_name - 20, // [20:20] is the sub-list for extension extendee - 0, // [0:20] is the sub-list for field type_name + 31, // 19: memos.api.v1.UserSession.create_time:type_name -> google.protobuf.Timestamp + 31, // 20: memos.api.v1.UserSession.expire_time:type_name -> google.protobuf.Timestamp + 31, // 21: memos.api.v1.UserSession.last_accessed_time:type_name -> google.protobuf.Timestamp + 29, // 22: memos.api.v1.UserSession.client_info:type_name -> memos.api.v1.UserSession.ClientInfo + 21, // 23: memos.api.v1.ListUserSessionsResponse.sessions:type_name -> memos.api.v1.UserSession + 11, // 24: memos.api.v1.ListAllUserStatsResponse.user_stats:type_name -> memos.api.v1.UserStats + 2, // 25: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest + 4, // 26: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest + 5, // 27: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest + 6, // 28: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest + 7, // 29: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest + 8, // 30: memos.api.v1.UserService.SearchUsers:input_type -> memos.api.v1.SearchUsersRequest + 10, // 31: memos.api.v1.UserService.GetUserAvatar:input_type -> memos.api.v1.GetUserAvatarRequest + 25, // 32: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest + 12, // 33: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest + 14, // 34: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest + 15, // 35: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest + 17, // 36: memos.api.v1.UserService.ListUserAccessTokens:input_type -> memos.api.v1.ListUserAccessTokensRequest + 19, // 37: memos.api.v1.UserService.CreateUserAccessToken:input_type -> memos.api.v1.CreateUserAccessTokenRequest + 20, // 38: memos.api.v1.UserService.DeleteUserAccessToken:input_type -> memos.api.v1.DeleteUserAccessTokenRequest + 22, // 39: memos.api.v1.UserService.ListUserSessions:input_type -> memos.api.v1.ListUserSessionsRequest + 24, // 40: memos.api.v1.UserService.RevokeUserSession:input_type -> memos.api.v1.RevokeUserSessionRequest + 3, // 41: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse + 1, // 42: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User + 1, // 43: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User + 1, // 44: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User + 33, // 45: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty + 9, // 46: memos.api.v1.UserService.SearchUsers:output_type -> memos.api.v1.SearchUsersResponse + 34, // 47: memos.api.v1.UserService.GetUserAvatar:output_type -> google.api.HttpBody + 26, // 48: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse + 11, // 49: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats + 13, // 50: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting + 13, // 51: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting + 18, // 52: memos.api.v1.UserService.ListUserAccessTokens:output_type -> memos.api.v1.ListUserAccessTokensResponse + 16, // 53: memos.api.v1.UserService.CreateUserAccessToken:output_type -> memos.api.v1.UserAccessToken + 33, // 54: memos.api.v1.UserService.DeleteUserAccessToken:output_type -> google.protobuf.Empty + 23, // 55: memos.api.v1.UserService.ListUserSessions:output_type -> memos.api.v1.ListUserSessionsResponse + 33, // 56: memos.api.v1.UserService.RevokeUserSession:output_type -> google.protobuf.Empty + 41, // [41:57] is the sub-list for method output_type + 25, // [25:41] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name } func init() { file_api_v1_user_service_proto_init() } @@ -1911,7 +2276,7 @@ func file_api_v1_user_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_user_service_proto_rawDesc), len(file_api_v1_user_service_proto_rawDesc)), NumEnums: 1, - NumMessages: 24, + NumMessages: 29, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/gen/api/v1/user_service.pb.gw.go b/proto/gen/api/v1/user_service.pb.gw.go index 53ef79ef9..0432db86d 100644 --- a/proto/gen/api/v1/user_service.pb.gw.go +++ b/proto/gen/api/v1/user_service.pb.gw.go @@ -717,6 +717,84 @@ func local_request_UserService_DeleteUserAccessToken_0(ctx context.Context, mars return msg, metadata, err } +func request_UserService_ListUserSessions_0(ctx context.Context, marshaler runtime.Marshaler, client UserServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListUserSessionsRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["parent"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "parent") + } + protoReq.Parent, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "parent", err) + } + msg, err := client.ListUserSessions(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_UserService_ListUserSessions_0(ctx context.Context, marshaler runtime.Marshaler, server UserServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListUserSessionsRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["parent"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "parent") + } + protoReq.Parent, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "parent", err) + } + msg, err := server.ListUserSessions(ctx, &protoReq) + return msg, metadata, err +} + +func request_UserService_RevokeUserSession_0(ctx context.Context, marshaler runtime.Marshaler, client UserServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RevokeUserSessionRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["name"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "name") + } + protoReq.Name, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "name", err) + } + msg, err := client.RevokeUserSession(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_UserService_RevokeUserSession_0(ctx context.Context, marshaler runtime.Marshaler, server UserServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RevokeUserSessionRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["name"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "name") + } + protoReq.Name, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "name", err) + } + msg, err := server.RevokeUserSession(ctx, &protoReq) + return msg, metadata, err +} + // RegisterUserServiceHandlerServer registers the http handlers for service UserService to "mux". // UnaryRPC :call UserServiceServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -1003,6 +1081,46 @@ func RegisterUserServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux } forward_UserService_DeleteUserAccessToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodGet, pattern_UserService_ListUserSessions_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.UserService/ListUserSessions", runtime.WithHTTPPathPattern("/api/v1/{parent=users/*}/sessions")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_UserService_ListUserSessions_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_UserService_ListUserSessions_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_UserService_RevokeUserSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.UserService/RevokeUserSession", runtime.WithHTTPPathPattern("/api/v1/{name=users/*/sessions/*}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_UserService_RevokeUserSession_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_UserService_RevokeUserSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -1281,6 +1399,40 @@ func RegisterUserServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux } forward_UserService_DeleteUserAccessToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodGet, pattern_UserService_ListUserSessions_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.UserService/ListUserSessions", runtime.WithHTTPPathPattern("/api/v1/{parent=users/*}/sessions")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_UserService_ListUserSessions_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_UserService_ListUserSessions_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_UserService_RevokeUserSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.UserService/RevokeUserSession", runtime.WithHTTPPathPattern("/api/v1/{name=users/*/sessions/*}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_UserService_RevokeUserSession_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_UserService_RevokeUserSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -1299,6 +1451,8 @@ var ( pattern_UserService_ListUserAccessTokens_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3, 2, 4}, []string{"api", "v1", "users", "parent", "accessTokens"}, "")) pattern_UserService_CreateUserAccessToken_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3, 2, 4}, []string{"api", "v1", "users", "parent", "accessTokens"}, "")) pattern_UserService_DeleteUserAccessToken_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 2, 3, 1, 0, 4, 4, 5, 4}, []string{"api", "v1", "users", "accessTokens", "name"}, "")) + pattern_UserService_ListUserSessions_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3, 2, 4}, []string{"api", "v1", "users", "parent", "sessions"}, "")) + pattern_UserService_RevokeUserSession_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 2, 3, 1, 0, 4, 4, 5, 4}, []string{"api", "v1", "users", "sessions", "name"}, "")) ) var ( @@ -1316,4 +1470,6 @@ var ( forward_UserService_ListUserAccessTokens_0 = runtime.ForwardResponseMessage forward_UserService_CreateUserAccessToken_0 = runtime.ForwardResponseMessage forward_UserService_DeleteUserAccessToken_0 = runtime.ForwardResponseMessage + forward_UserService_ListUserSessions_0 = runtime.ForwardResponseMessage + forward_UserService_RevokeUserSession_0 = runtime.ForwardResponseMessage ) diff --git a/proto/gen/api/v1/user_service_grpc.pb.go b/proto/gen/api/v1/user_service_grpc.pb.go index 127fdfb5b..8f2c5560b 100644 --- a/proto/gen/api/v1/user_service_grpc.pb.go +++ b/proto/gen/api/v1/user_service_grpc.pb.go @@ -35,6 +35,8 @@ const ( UserService_ListUserAccessTokens_FullMethodName = "/memos.api.v1.UserService/ListUserAccessTokens" UserService_CreateUserAccessToken_FullMethodName = "/memos.api.v1.UserService/CreateUserAccessToken" UserService_DeleteUserAccessToken_FullMethodName = "/memos.api.v1.UserService/DeleteUserAccessToken" + UserService_ListUserSessions_FullMethodName = "/memos.api.v1.UserService/ListUserSessions" + UserService_RevokeUserSession_FullMethodName = "/memos.api.v1.UserService/RevokeUserSession" ) // UserServiceClient is the client API for UserService service. @@ -69,6 +71,10 @@ type UserServiceClient interface { CreateUserAccessToken(ctx context.Context, in *CreateUserAccessTokenRequest, opts ...grpc.CallOption) (*UserAccessToken, error) // DeleteUserAccessToken deletes an access token. DeleteUserAccessToken(ctx context.Context, in *DeleteUserAccessTokenRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // ListUserSessions returns a list of active sessions for a user. + ListUserSessions(ctx context.Context, in *ListUserSessionsRequest, opts ...grpc.CallOption) (*ListUserSessionsResponse, error) + // RevokeUserSession revokes a specific session for a user. + RevokeUserSession(ctx context.Context, in *RevokeUserSessionRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) } type userServiceClient struct { @@ -219,6 +225,26 @@ func (c *userServiceClient) DeleteUserAccessToken(ctx context.Context, in *Delet return out, nil } +func (c *userServiceClient) ListUserSessions(ctx context.Context, in *ListUserSessionsRequest, opts ...grpc.CallOption) (*ListUserSessionsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListUserSessionsResponse) + err := c.cc.Invoke(ctx, UserService_ListUserSessions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *userServiceClient) RevokeUserSession(ctx context.Context, in *RevokeUserSessionRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, UserService_RevokeUserSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // UserServiceServer is the server API for UserService service. // All implementations must embed UnimplementedUserServiceServer // for forward compatibility. @@ -251,6 +277,10 @@ type UserServiceServer interface { CreateUserAccessToken(context.Context, *CreateUserAccessTokenRequest) (*UserAccessToken, error) // DeleteUserAccessToken deletes an access token. DeleteUserAccessToken(context.Context, *DeleteUserAccessTokenRequest) (*emptypb.Empty, error) + // ListUserSessions returns a list of active sessions for a user. + ListUserSessions(context.Context, *ListUserSessionsRequest) (*ListUserSessionsResponse, error) + // RevokeUserSession revokes a specific session for a user. + RevokeUserSession(context.Context, *RevokeUserSessionRequest) (*emptypb.Empty, error) mustEmbedUnimplementedUserServiceServer() } @@ -303,6 +333,12 @@ func (UnimplementedUserServiceServer) CreateUserAccessToken(context.Context, *Cr func (UnimplementedUserServiceServer) DeleteUserAccessToken(context.Context, *DeleteUserAccessTokenRequest) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method DeleteUserAccessToken not implemented") } +func (UnimplementedUserServiceServer) ListUserSessions(context.Context, *ListUserSessionsRequest) (*ListUserSessionsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListUserSessions not implemented") +} +func (UnimplementedUserServiceServer) RevokeUserSession(context.Context, *RevokeUserSessionRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method RevokeUserSession not implemented") +} func (UnimplementedUserServiceServer) mustEmbedUnimplementedUserServiceServer() {} func (UnimplementedUserServiceServer) testEmbeddedByValue() {} @@ -576,6 +612,42 @@ func _UserService_DeleteUserAccessToken_Handler(srv interface{}, ctx context.Con return interceptor(ctx, in, info, handler) } +func _UserService_ListUserSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListUserSessionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserServiceServer).ListUserSessions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserService_ListUserSessions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserServiceServer).ListUserSessions(ctx, req.(*ListUserSessionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _UserService_RevokeUserSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeUserSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserServiceServer).RevokeUserSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserService_RevokeUserSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserServiceServer).RevokeUserSession(ctx, req.(*RevokeUserSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + // UserService_ServiceDesc is the grpc.ServiceDesc for UserService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -639,6 +711,14 @@ var UserService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DeleteUserAccessToken", Handler: _UserService_DeleteUserAccessToken_Handler, }, + { + MethodName: "ListUserSessions", + Handler: _UserService_ListUserSessions_Handler, + }, + { + MethodName: "RevokeUserSession", + Handler: _UserService_RevokeUserSession_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "api/v1/user_service.proto", diff --git a/proto/gen/apidocs.swagger.yaml b/proto/gen/apidocs.swagger.yaml index 29c2738fa..169ae5b5a 100644 --- a/proto/gen/apidocs.swagger.yaml +++ b/proto/gen/apidocs.swagger.yaml @@ -1193,8 +1193,8 @@ paths: tags: - IdentityProviderService delete: - summary: DeleteIdentityProvider deletes an identity provider. - operationId: IdentityProviderService_DeleteIdentityProvider + summary: RevokeUserSession revokes a specific session for a user. + operationId: UserService_RevokeUserSession responses: "200": description: A successful response. @@ -1208,14 +1208,14 @@ paths: parameters: - name: name_3 description: |- - Required. The resource name of the identity provider to delete. - Format: identityProviders/{idp} + Required. The resource name of the session to revoke. + Format: users/{user}/sessions/{session} in: path required: true type: string - pattern: identityProviders/[^/]+ + pattern: users/[^/]+/sessions/[^/]+ tags: - - IdentityProviderService + - UserService /api/v1/{name_4}: get: summary: GetMemo gets a memo. @@ -1248,8 +1248,8 @@ paths: tags: - MemoService delete: - summary: DeleteInbox deletes an inbox. - operationId: InboxService_DeleteInbox + summary: DeleteIdentityProvider deletes an identity provider. + operationId: IdentityProviderService_DeleteIdentityProvider responses: "200": description: A successful response. @@ -1263,14 +1263,14 @@ paths: parameters: - name: name_4 description: |- - Required. The resource name of the inbox to delete. - Format: inboxes/{inbox} + Required. The resource name of the identity provider to delete. + Format: identityProviders/{idp} in: path required: true type: string - pattern: inboxes/[^/]+ + pattern: identityProviders/[^/]+ tags: - - InboxService + - IdentityProviderService /api/v1/{name_5}: get: summary: GetShortcut gets a shortcut by name. @@ -1296,8 +1296,8 @@ paths: tags: - ShortcutService delete: - summary: DeleteMemo deletes a memo. - operationId: MemoService_DeleteMemo + summary: DeleteInbox deletes an inbox. + operationId: InboxService_DeleteInbox responses: "200": description: A successful response. @@ -1311,19 +1311,14 @@ paths: parameters: - name: name_5 description: |- - Required. The resource name of the memo to delete. - Format: memos/{memo} + Required. The resource name of the inbox to delete. + Format: inboxes/{inbox} in: path required: true type: string - pattern: memos/[^/]+ - - name: force - description: Optional. If set to true, the memo will be deleted even if it has associated data. - in: query - required: false - type: boolean + pattern: inboxes/[^/]+ tags: - - MemoService + - InboxService /api/v1/{name_6}: get: summary: GetWebhook gets a webhook by name. @@ -1356,8 +1351,8 @@ paths: tags: - WebhookService delete: - summary: DeleteMemoReaction deletes a reaction for a memo. - operationId: MemoService_DeleteMemoReaction + summary: DeleteMemo deletes a memo. + operationId: MemoService_DeleteMemo responses: "200": description: A successful response. @@ -1371,12 +1366,17 @@ paths: parameters: - name: name_6 description: |- - Required. The resource name of the reaction to delete. - Format: reactions/{reaction} + Required. The resource name of the memo to delete. + Format: memos/{memo} in: path required: true type: string - pattern: reactions/[^/]+ + pattern: memos/[^/]+ + - name: force + description: Optional. If set to true, the memo will be deleted even if it has associated data. + in: query + required: false + type: boolean tags: - MemoService /api/v1/{name_7}: @@ -1403,6 +1403,31 @@ paths: pattern: workspace/settings/[^/]+ tags: - WorkspaceService + delete: + summary: DeleteMemoReaction deletes a reaction for a memo. + operationId: MemoService_DeleteMemoReaction + responses: + "200": + description: A successful response. + schema: + type: object + properties: {} + default: + description: An unexpected error response. + schema: + $ref: '#/definitions/googlerpcStatus' + parameters: + - name: name_7 + description: |- + Required. The resource name of the reaction to delete. + Format: reactions/{reaction} + in: path + required: true + type: string + pattern: reactions/[^/]+ + tags: + - MemoService + /api/v1/{name_8}: delete: summary: DeleteShortcut deletes a shortcut for a user. operationId: ShortcutService_DeleteShortcut @@ -1417,7 +1442,7 @@ paths: schema: $ref: '#/definitions/googlerpcStatus' parameters: - - name: name_7 + - name: name_8 description: |- Required. The resource name of the shortcut to delete. Format: users/{user}/shortcuts/{shortcut} @@ -1427,7 +1452,7 @@ paths: pattern: users/[^/]+/shortcuts/[^/]+ tags: - ShortcutService - /api/v1/{name_8}: + /api/v1/{name_9}: delete: summary: DeleteWebhook deletes a webhook. operationId: WebhookService_DeleteWebhook @@ -1442,7 +1467,7 @@ paths: schema: $ref: '#/definitions/googlerpcStatus' parameters: - - name: name_8 + - name: name_9 description: |- Required. The resource name of the webhook to delete. Format: webhooks/{webhook} @@ -2054,6 +2079,30 @@ paths: type: string tags: - MemoService + /api/v1/{parent}/sessions: + get: + summary: ListUserSessions returns a list of active sessions for a user. + operationId: UserService_ListUserSessions + responses: + "200": + description: A successful response. + schema: + $ref: '#/definitions/v1ListUserSessionsResponse' + default: + description: An unexpected error response. + schema: + $ref: '#/definitions/googlerpcStatus' + parameters: + - name: parent + description: |- + Required. The resource name of the parent. + Format: users/{user} + in: path + required: true + type: string + pattern: users/[^/]+ + tags: + - UserService /api/v1/{parent}/shortcuts: get: summary: ListShortcuts returns a list of shortcuts for a user. @@ -3676,6 +3725,15 @@ definitions: type: integer format: int32 description: The total count of access tokens. + v1ListUserSessionsResponse: + type: object + properties: + sessions: + type: array + items: + type: object + $ref: '#/definitions/v1UserSession' + description: The list of user sessions. v1ListUsersResponse: type: object properties: @@ -4233,6 +4291,58 @@ definitions: format: date-time description: Optional. The expiration timestamp. title: User access token message + v1UserSession: + type: object + properties: + name: + type: string + title: |- + The resource name of the session. + Format: users/{user}/sessions/{session} + sessionId: + type: string + description: The session ID. + readOnly: true + createTime: + type: string + format: date-time + description: The timestamp when the session was created. + readOnly: true + expireTime: + type: string + format: date-time + description: The timestamp when the session expires. + readOnly: true + lastAccessedTime: + type: string + format: date-time + description: The timestamp when the session was last accessed. + readOnly: true + clientInfo: + $ref: '#/definitions/v1UserSessionClientInfo' + description: Client information associated with this session. + readOnly: true + v1UserSessionClientInfo: + type: object + properties: + userAgent: + type: string + description: User agent string of the client. + ipAddress: + type: string + description: IP address of the client. + deviceType: + type: string + description: Optional. Device type (e.g., "mobile", "desktop", "tablet"). + os: + type: string + description: Optional. Operating system (e.g., "iOS 17.0", "Windows 11"). + browser: + type: string + description: Optional. Browser name and version (e.g., "Chrome 119.0"). + country: + type: string + description: Optional. Geographic location (country code, e.g., "US"). v1UserStats: type: object properties: diff --git a/server/router/api/v1/auth_service.go b/server/router/api/v1/auth_service.go index bb87a9701..868e61a4c 100644 --- a/server/router/api/v1/auth_service.go +++ b/server/router/api/v1/auth_service.go @@ -15,6 +15,7 @@ import ( "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/usememos/memos/internal/base" "github.com/usememos/memos/internal/util" @@ -176,6 +177,13 @@ func (s *APIV1Service) doSignIn(ctx context.Context, user *store.User, expireTim return status.Errorf(codes.Internal, "failed to upsert access token to store, error: %v", err) } + // Track session in user settings + if err := s.trackUserSession(ctx, user.ID, accessToken, expireTime); err != nil { + // Log the error but don't fail the login if session tracking fails + // This ensures backward compatibility + // TODO: Add proper logging here + } + cookie, err := s.buildAccessTokenCookie(ctx, accessToken, expireTime) if err != nil { return status.Errorf(codes.Internal, "failed to build access token cookie, error: %v", err) @@ -313,3 +321,41 @@ func (s *APIV1Service) GetCurrentUser(ctx context.Context) (*store.User, error) } return user, nil } + +// Helper function to track user session for session management +func (s *APIV1Service) trackUserSession(ctx context.Context, userID int32, sessionID string, expireTime time.Time) error { + // Extract client information from the context + clientInfo := s.extractClientInfo(ctx) + + session := &storepb.SessionsUserSetting_Session{ + SessionId: sessionID, + CreateTime: timestamppb.Now(), + ExpireTime: timestamppb.New(expireTime), + LastAccessedTime: timestamppb.Now(), + ClientInfo: clientInfo, + } + + return s.Store.AddUserSession(ctx, userID, session) +} + +// Helper function to extract client information from the gRPC context +func (s *APIV1Service) extractClientInfo(ctx context.Context) *storepb.SessionsUserSetting_ClientInfo { + clientInfo := &storepb.SessionsUserSetting_ClientInfo{} + + // Extract user agent from metadata if available + if md, ok := metadata.FromIncomingContext(ctx); ok { + if userAgents := md.Get("user-agent"); len(userAgents) > 0 { + clientInfo.UserAgent = userAgents[0] + } + if forwardedFor := md.Get("x-forwarded-for"); len(forwardedFor) > 0 { + clientInfo.IpAddress = forwardedFor[0] + } else if realIP := md.Get("x-real-ip"); len(realIP) > 0 { + clientInfo.IpAddress = realIP[0] + } + } + + // TODO: Parse user agent to extract device type, OS, browser info + // This could be done using a user agent parsing library + + return clientInfo +} diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index b734c1d84..082e821fe 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -588,6 +588,108 @@ func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *v1pb. return &emptypb.Empty{}, nil } +func (s *APIV1Service) ListUserSessions(ctx context.Context, request *v1pb.ListUserSessionsRequest) (*v1pb.ListUserSessionsResponse, error) { + userID, err := ExtractUserIDFromName(request.Parent) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) + } + + currentUser, err := s.GetCurrentUser(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err) + } + if currentUser == nil { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + if currentUser.ID != userID { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + + userSessions, err := s.Store.GetUserSessions(ctx, userID) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list sessions: %v", err) + } + + sessions := []*v1pb.UserSession{} + for _, userSession := range userSessions { + sessionResponse := &v1pb.UserSession{ + Name: fmt.Sprintf("users/%d/sessions/%s", userID, userSession.SessionId), + SessionId: userSession.SessionId, + CreateTime: userSession.CreateTime, + ExpireTime: userSession.ExpireTime, + LastAccessedTime: userSession.LastAccessedTime, + } + + if userSession.ClientInfo != nil { + sessionResponse.ClientInfo = &v1pb.UserSession_ClientInfo{ + UserAgent: userSession.ClientInfo.UserAgent, + IpAddress: userSession.ClientInfo.IpAddress, + DeviceType: userSession.ClientInfo.DeviceType, + Os: userSession.ClientInfo.Os, + Browser: userSession.ClientInfo.Browser, + Country: userSession.ClientInfo.Country, + } + } + + sessions = append(sessions, sessionResponse) + } + + // Sort by last accessed time in descending order. + slices.SortFunc(sessions, func(i, j *v1pb.UserSession) int { + return int(j.LastAccessedTime.Seconds - i.LastAccessedTime.Seconds) + }) + + response := &v1pb.ListUserSessionsResponse{ + Sessions: sessions, + } + return response, nil +} + +func (s *APIV1Service) RevokeUserSession(ctx context.Context, request *v1pb.RevokeUserSessionRequest) (*emptypb.Empty, error) { + // Extract user ID and session ID from the session resource name + // Format: users/{user}/sessions/{session} + parts := strings.Split(request.Name, "/") + if len(parts) != 4 || parts[0] != "users" || parts[2] != "sessions" { + return nil, status.Errorf(codes.InvalidArgument, "invalid session name format: %s", request.Name) + } + + userID, err := ExtractUserIDFromName(fmt.Sprintf("users/%s", parts[1])) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) + } + sessionIDToRevoke := parts[3] + + currentUser, err := s.GetCurrentUser(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err) + } + if currentUser == nil { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + if currentUser.ID != userID { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + + if err := s.Store.RemoveUserSession(ctx, userID, sessionIDToRevoke); err != nil { + return nil, status.Errorf(codes.Internal, "failed to revoke session: %v", err) + } + + return &emptypb.Empty{}, nil +} + +// Helper function to add or update a user session +func (s *APIV1Service) UpsertUserSession(ctx context.Context, userID int32, sessionID string, clientInfo *storepb.SessionsUserSetting_ClientInfo) error { + session := &storepb.SessionsUserSetting_Session{ + SessionId: sessionID, + CreateTime: timestamppb.Now(), + ExpireTime: timestamppb.New(time.Now().Add(30 * 24 * time.Hour)), // 30 days default + LastAccessedTime: timestamppb.Now(), + ClientInfo: clientInfo, + } + + return s.Store.AddUserSession(ctx, userID, session) +} + func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error { userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID) if err != nil { @@ -598,6 +700,7 @@ func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store Description: description, } userAccessTokens = append(userAccessTokens, &userAccessToken) + if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{ UserId: user.ID, Key: storepb.UserSettingKey_ACCESS_TOKENS, diff --git a/store/user_setting.go b/store/user_setting.go index a20532850..7536a1d9b 100644 --- a/store/user_setting.go +++ b/store/user_setting.go @@ -5,6 +5,7 @@ import ( "github.com/pkg/errors" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/timestamppb" storepb "github.com/usememos/memos/proto/gen/store" ) @@ -132,6 +133,114 @@ func (s *Store) RemoveUserAccessToken(ctx context.Context, userID int32, token s return err } +// GetUserSessions returns the sessions of the user. +func (s *Store) GetUserSessions(ctx context.Context, userID int32) ([]*storepb.SessionsUserSetting_Session, error) { + userSetting, err := s.GetUserSetting(ctx, &FindUserSetting{ + UserID: &userID, + Key: storepb.UserSettingKey_SESSIONS, + }) + if err != nil { + return nil, err + } + if userSetting == nil { + return []*storepb.SessionsUserSetting_Session{}, nil + } + + sessionsUserSetting := userSetting.GetSessions() + return sessionsUserSetting.Sessions, nil +} + +// RemoveUserSession removes the session of the user. +func (s *Store) RemoveUserSession(ctx context.Context, userID int32, sessionID string) error { + oldSessions, err := s.GetUserSessions(ctx, userID) + if err != nil { + return err + } + + newSessions := make([]*storepb.SessionsUserSetting_Session, 0, len(oldSessions)) + for _, session := range oldSessions { + if sessionID != session.SessionId { + newSessions = append(newSessions, session) + } + } + + _, err = s.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: userID, + Key: storepb.UserSettingKey_SESSIONS, + Value: &storepb.UserSetting_Sessions{ + Sessions: &storepb.SessionsUserSetting{ + Sessions: newSessions, + }, + }, + }) + + return err +} + +// AddUserSession adds a new session for the user. +func (s *Store) AddUserSession(ctx context.Context, userID int32, session *storepb.SessionsUserSetting_Session) error { + existingSessions, err := s.GetUserSessions(ctx, userID) + if err != nil { + return err + } + + // Check if session already exists, update if it does + var updatedSessions []*storepb.SessionsUserSetting_Session + sessionExists := false + for _, existing := range existingSessions { + if existing.SessionId == session.SessionId { + updatedSessions = append(updatedSessions, session) + sessionExists = true + } else { + updatedSessions = append(updatedSessions, existing) + } + } + + // If session doesn't exist, add it + if !sessionExists { + updatedSessions = append(updatedSessions, session) + } + + _, err = s.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: userID, + Key: storepb.UserSettingKey_SESSIONS, + Value: &storepb.UserSetting_Sessions{ + Sessions: &storepb.SessionsUserSetting{ + Sessions: updatedSessions, + }, + }, + }) + + return err +} + +// UpdateUserSessionLastAccessed updates the last accessed time of a session. +func (s *Store) UpdateUserSessionLastAccessed(ctx context.Context, userID int32, sessionID string, lastAccessedTime *timestamppb.Timestamp) error { + sessions, err := s.GetUserSessions(ctx, userID) + if err != nil { + return err + } + + for _, session := range sessions { + if session.SessionId == sessionID { + session.LastAccessedTime = lastAccessedTime + break + } + } + + _, err = s.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: userID, + Key: storepb.UserSettingKey_SESSIONS, + Value: &storepb.UserSetting_Sessions{ + Sessions: &storepb.SessionsUserSetting{ + Sessions: sessions, + }, + }, + }) + + return err +} + func convertUserSettingFromRaw(raw *UserSetting) (*storepb.UserSetting, error) { userSetting := &storepb.UserSetting{ UserId: raw.UserID, @@ -145,6 +254,12 @@ func convertUserSettingFromRaw(raw *UserSetting) (*storepb.UserSetting, error) { return nil, err } userSetting.Value = &storepb.UserSetting_AccessTokens{AccessTokens: accessTokensUserSetting} + case storepb.UserSettingKey_SESSIONS: + sessionsUserSetting := &storepb.SessionsUserSetting{} + if err := protojsonUnmarshaler.Unmarshal([]byte(raw.Value), sessionsUserSetting); err != nil { + return nil, err + } + userSetting.Value = &storepb.UserSetting_Sessions{Sessions: sessionsUserSetting} case storepb.UserSettingKey_SHORTCUTS: shortcutsUserSetting := &storepb.ShortcutsUserSetting{} if err := protojsonUnmarshaler.Unmarshal([]byte(raw.Value), shortcutsUserSetting); err != nil { @@ -177,6 +292,13 @@ func convertUserSettingToRaw(userSetting *storepb.UserSetting) (*UserSetting, er return nil, err } raw.Value = string(value) + case storepb.UserSettingKey_SESSIONS: + sessionsUserSetting := userSetting.GetSessions() + value, err := protojson.Marshal(sessionsUserSetting) + if err != nil { + return nil, err + } + raw.Value = string(value) case storepb.UserSettingKey_SHORTCUTS: shortcutsUserSetting := userSetting.GetShortcuts() value, err := protojson.Marshal(shortcutsUserSetting) diff --git a/web/src/types/proto/api/v1/user_service.ts b/web/src/types/proto/api/v1/user_service.ts index a2cb9e2f5..e899cb6f4 100644 --- a/web/src/types/proto/api/v1/user_service.ts +++ b/web/src/types/proto/api/v1/user_service.ts @@ -359,6 +359,66 @@ export interface DeleteUserAccessTokenRequest { name: string; } +export interface UserSession { + /** + * The resource name of the session. + * Format: users/{user}/sessions/{session} + */ + name: string; + /** The session ID. */ + sessionId: string; + /** The timestamp when the session was created. */ + createTime?: + | Date + | undefined; + /** The timestamp when the session expires. */ + expireTime?: + | Date + | undefined; + /** The timestamp when the session was last accessed. */ + lastAccessedTime?: + | Date + | undefined; + /** Client information associated with this session. */ + clientInfo?: UserSession_ClientInfo | undefined; +} + +export interface UserSession_ClientInfo { + /** User agent string of the client. */ + userAgent: string; + /** IP address of the client. */ + ipAddress: string; + /** Optional. Device type (e.g., "mobile", "desktop", "tablet"). */ + deviceType: string; + /** Optional. Operating system (e.g., "iOS 17.0", "Windows 11"). */ + os: string; + /** Optional. Browser name and version (e.g., "Chrome 119.0"). */ + browser: string; + /** Optional. Geographic location (country code, e.g., "US"). */ + country: string; +} + +export interface ListUserSessionsRequest { + /** + * Required. The resource name of the parent. + * Format: users/{user} + */ + parent: string; +} + +export interface ListUserSessionsResponse { + /** The list of user sessions. */ + sessions: UserSession[]; +} + +export interface RevokeUserSessionRequest { + /** + * Required. The resource name of the session to revoke. + * Format: users/{user}/sessions/{session} + */ + name: string; +} + export interface ListAllUserStatsRequest { /** Optional. The maximum number of user stats to return. */ pageSize: number; @@ -2046,6 +2106,365 @@ export const DeleteUserAccessTokenRequest: MessageFns = { + encode(message: UserSession, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.sessionId !== "") { + writer.uint32(18).string(message.sessionId); + } + if (message.createTime !== undefined) { + Timestamp.encode(toTimestamp(message.createTime), writer.uint32(26).fork()).join(); + } + if (message.expireTime !== undefined) { + Timestamp.encode(toTimestamp(message.expireTime), writer.uint32(34).fork()).join(); + } + if (message.lastAccessedTime !== undefined) { + Timestamp.encode(toTimestamp(message.lastAccessedTime), writer.uint32(42).fork()).join(); + } + if (message.clientInfo !== undefined) { + UserSession_ClientInfo.encode(message.clientInfo, writer.uint32(50).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): UserSession { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUserSession(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.sessionId = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.createTime = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.expireTime = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.lastAccessedTime = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.clientInfo = UserSession_ClientInfo.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create(base?: DeepPartial): UserSession { + return UserSession.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): UserSession { + const message = createBaseUserSession(); + message.name = object.name ?? ""; + message.sessionId = object.sessionId ?? ""; + message.createTime = object.createTime ?? undefined; + message.expireTime = object.expireTime ?? undefined; + message.lastAccessedTime = object.lastAccessedTime ?? undefined; + message.clientInfo = (object.clientInfo !== undefined && object.clientInfo !== null) + ? UserSession_ClientInfo.fromPartial(object.clientInfo) + : undefined; + return message; + }, +}; + +function createBaseUserSession_ClientInfo(): UserSession_ClientInfo { + return { userAgent: "", ipAddress: "", deviceType: "", os: "", browser: "", country: "" }; +} + +export const UserSession_ClientInfo: MessageFns = { + encode(message: UserSession_ClientInfo, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.userAgent !== "") { + writer.uint32(10).string(message.userAgent); + } + if (message.ipAddress !== "") { + writer.uint32(18).string(message.ipAddress); + } + if (message.deviceType !== "") { + writer.uint32(26).string(message.deviceType); + } + if (message.os !== "") { + writer.uint32(34).string(message.os); + } + if (message.browser !== "") { + writer.uint32(42).string(message.browser); + } + if (message.country !== "") { + writer.uint32(50).string(message.country); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): UserSession_ClientInfo { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUserSession_ClientInfo(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.userAgent = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.ipAddress = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.deviceType = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.os = reader.string(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.browser = reader.string(); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.country = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create(base?: DeepPartial): UserSession_ClientInfo { + return UserSession_ClientInfo.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): UserSession_ClientInfo { + const message = createBaseUserSession_ClientInfo(); + message.userAgent = object.userAgent ?? ""; + message.ipAddress = object.ipAddress ?? ""; + message.deviceType = object.deviceType ?? ""; + message.os = object.os ?? ""; + message.browser = object.browser ?? ""; + message.country = object.country ?? ""; + return message; + }, +}; + +function createBaseListUserSessionsRequest(): ListUserSessionsRequest { + return { parent: "" }; +} + +export const ListUserSessionsRequest: MessageFns = { + encode(message: ListUserSessionsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.parent !== "") { + writer.uint32(10).string(message.parent); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ListUserSessionsRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseListUserSessionsRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.parent = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create(base?: DeepPartial): ListUserSessionsRequest { + return ListUserSessionsRequest.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): ListUserSessionsRequest { + const message = createBaseListUserSessionsRequest(); + message.parent = object.parent ?? ""; + return message; + }, +}; + +function createBaseListUserSessionsResponse(): ListUserSessionsResponse { + return { sessions: [] }; +} + +export const ListUserSessionsResponse: MessageFns = { + encode(message: ListUserSessionsResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.sessions) { + UserSession.encode(v!, writer.uint32(10).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ListUserSessionsResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseListUserSessionsResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.sessions.push(UserSession.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create(base?: DeepPartial): ListUserSessionsResponse { + return ListUserSessionsResponse.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): ListUserSessionsResponse { + const message = createBaseListUserSessionsResponse(); + message.sessions = object.sessions?.map((e) => UserSession.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseRevokeUserSessionRequest(): RevokeUserSessionRequest { + return { name: "" }; +} + +export const RevokeUserSessionRequest: MessageFns = { + encode(message: RevokeUserSessionRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== "") { + writer.uint32(10).string(message.name); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): RevokeUserSessionRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRevokeUserSessionRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + create(base?: DeepPartial): RevokeUserSessionRequest { + return RevokeUserSessionRequest.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): RevokeUserSessionRequest { + const message = createBaseRevokeUserSessionRequest(); + message.name = object.name ?? ""; + return message; + }, +}; + function createBaseListAllUserStatsRequest(): ListAllUserStatsRequest { return { pageSize: 0, pageToken: "" }; } @@ -2903,6 +3322,112 @@ export const UserServiceDefinition = { }, }, }, + /** ListUserSessions returns a list of active sessions for a user. */ + listUserSessions: { + name: "ListUserSessions", + requestType: ListUserSessionsRequest, + requestStream: false, + responseType: ListUserSessionsResponse, + responseStream: false, + options: { + _unknownFields: { + 8410: [new Uint8Array([6, 112, 97, 114, 101, 110, 116])], + 578365826: [ + new Uint8Array([ + 35, + 18, + 33, + 47, + 97, + 112, + 105, + 47, + 118, + 49, + 47, + 123, + 112, + 97, + 114, + 101, + 110, + 116, + 61, + 117, + 115, + 101, + 114, + 115, + 47, + 42, + 125, + 47, + 115, + 101, + 115, + 115, + 105, + 111, + 110, + 115, + ]), + ], + }, + }, + }, + /** RevokeUserSession revokes a specific session for a user. */ + revokeUserSession: { + name: "RevokeUserSession", + requestType: RevokeUserSessionRequest, + requestStream: false, + responseType: Empty, + responseStream: false, + options: { + _unknownFields: { + 8410: [new Uint8Array([4, 110, 97, 109, 101])], + 578365826: [ + new Uint8Array([ + 35, + 42, + 33, + 47, + 97, + 112, + 105, + 47, + 118, + 49, + 47, + 123, + 110, + 97, + 109, + 101, + 61, + 117, + 115, + 101, + 114, + 115, + 47, + 42, + 47, + 115, + 101, + 115, + 115, + 105, + 111, + 110, + 115, + 47, + 42, + 125, + ]), + ], + }, + }, + }, }, } as const;