memos/server/router/api/v1/user_service.go
Steven d1492007ab fix(store): filter inbox notifications by message type at database level
Add MessageType filter to FindInbox to exclude legacy VERSION_UPDATE
notifications from inbox queries. This resolves the issue where users
saw notification counts but no items displayed, as VERSION_UPDATE
entries cannot be rendered in the new UserNotification API.

Changes:
- Add MessageType field to FindInbox struct for database-level filtering
- Implement JSON extraction filters in SQLite, MySQL, and PostgreSQL drivers
- Update ListUserNotifications to filter MEMO_COMMENT type at store level

This approach improves performance by filtering at the database rather
than in application code, reducing unnecessary data transfer for users
with many legacy inbox entries.

Fixes #5278

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 08:43:05 +08:00

1768 lines
59 KiB
Go

package v1
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/ast"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc/codes"
"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"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersRequest) (*v1pb.ListUsersResponse, error) {
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
userFind := &store.FindUser{}
if request.Filter != "" {
username, err := extractUsernameFromFilter(request.Filter)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
}
if username != "" {
userFind.Username = &username
}
}
users, err := s.Store.ListUsers(ctx, userFind)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
}
// TODO: Implement proper ordering, and pagination
// For now, return all users with basic structure
response := &v1pb.ListUsersResponse{
Users: []*v1pb.User{},
TotalSize: int32(len(users)),
}
for _, user := range users {
response.Users = append(response.Users, convertUserFromStore(user))
}
return response, nil
}
func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest) (*v1pb.User, error) {
// Extract identifier from "users/{id_or_username}"
identifier := extractUserIdentifierFromName(request.Name)
if identifier == "" {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %s", request.Name)
}
var user *store.User
var err error
// Try to parse as numeric ID first
if userID, parseErr := strconv.ParseInt(identifier, 10, 32); parseErr == nil {
// It's a numeric ID
userID32 := int32(userID)
user, err = s.Store.GetUser(ctx, &store.FindUser{
ID: &userID32,
})
} else {
// It's a username
user, err = s.Store.GetUser(ctx, &store.FindUser{
Username: &identifier,
})
}
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
return convertUserFromStore(user), nil
}
func (s *APIV1Service) GetUserAvatar(ctx context.Context, request *v1pb.GetUserAvatarRequest) (*httpbody.HttpBody, error) {
userID, err := ExtractUserIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
if user.AvatarURL == "" {
return nil, status.Errorf(codes.NotFound, "avatar not found")
}
imageType, base64Data, err := extractImageInfo(user.AvatarURL)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to extract image info: %v", err)
}
imageData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to decode string: %v", err)
}
httpBody := &httpbody.HttpBody{
ContentType: imageType,
Data: imageData,
}
return httpBody, nil
}
func (s *APIV1Service) CreateUser(ctx context.Context, request *v1pb.CreateUserRequest) (*v1pb.User, error) {
// Get current user (might be nil for unauthenticated requests)
currentUser, _ := s.GetCurrentUser(ctx)
// Check if there are any existing users (for first-time setup detection)
limitOne := 1
allUsers, err := s.Store.ListUsers(ctx, &store.FindUser{Limit: &limitOne})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
}
isFirstUser := len(allUsers) == 0
// Check registration settings FIRST (unless it's the very first user)
if !isFirstUser {
// Only allow user registration if it is enabled in the settings, or if the user is a superuser
if currentUser == nil || !isSuperUser(currentUser) {
instanceGeneralSetting, err := s.Store.GetInstanceGeneralSetting(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get instance general setting, error: %v", err)
}
if instanceGeneralSetting.DisallowUserRegistration {
return nil, status.Errorf(codes.PermissionDenied, "user registration is not allowed")
}
}
}
// Determine the role to assign
var roleToAssign store.Role
if isFirstUser {
// First-time setup: create the first user as HOST (no authentication required)
roleToAssign = store.RoleHost
} else if currentUser != nil && currentUser.Role == store.RoleHost {
// Authenticated HOST user can create users with any role specified in request
if request.User.Role != v1pb.User_ROLE_UNSPECIFIED {
roleToAssign = convertUserRoleToStore(request.User.Role)
} else {
roleToAssign = store.RoleUser
}
} else {
// Unauthenticated or non-HOST users can only create normal users
roleToAssign = store.RoleUser
}
if !base.UIDMatcher.MatchString(strings.ToLower(request.User.Username)) {
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username)
}
// If validate_only is true, just validate without creating
if request.ValidateOnly {
// Perform validation checks without actually creating the user
return &v1pb.User{
Username: request.User.Username,
Email: request.User.Email,
DisplayName: request.User.DisplayName,
Role: convertUserRoleFromStore(roleToAssign),
}, nil
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
}
user, err := s.Store.CreateUser(ctx, &store.User{
Username: request.User.Username,
Role: roleToAssign,
Email: request.User.Email,
Nickname: request.User.DisplayName,
PasswordHash: string(passwordHash),
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
}
return convertUserFromStore(user), nil
}
func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserRequest) (*v1pb.User, error) {
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
}
userID, err := ExtractUserIDFromName(request.User.Name)
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 user: %v", err)
}
// Check permission.
// Only allow admin or self to update user.
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if user == nil {
// Handle allow_missing field
if request.AllowMissing {
// Could create user if missing, but for now return not found
return nil, status.Errorf(codes.NotFound, "user not found")
}
return nil, status.Errorf(codes.NotFound, "user not found")
}
currentTs := time.Now().Unix()
update := &store.UpdateUser{
ID: user.ID,
UpdatedTs: &currentTs,
}
instanceGeneralSetting, err := s.Store.GetInstanceGeneralSetting(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get instance general setting: %v", err)
}
for _, field := range request.UpdateMask.Paths {
switch field {
case "username":
if instanceGeneralSetting.DisallowChangeUsername {
return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change username")
}
if !base.UIDMatcher.MatchString(strings.ToLower(request.User.Username)) {
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username)
}
update.Username = &request.User.Username
case "display_name":
if instanceGeneralSetting.DisallowChangeNickname {
return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change nickname")
}
update.Nickname = &request.User.DisplayName
case "email":
update.Email = &request.User.Email
case "avatar_url":
update.AvatarURL = &request.User.AvatarUrl
case "description":
update.Description = &request.User.Description
case "role":
// Only allow admin to update role.
if currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
role := convertUserRoleToStore(request.User.Role)
update.Role = &role
case "password":
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
}
passwordHashStr := string(passwordHash)
update.PasswordHash = &passwordHashStr
case "state":
rowStatus := convertStateToStore(request.User.State)
update.RowStatus = &rowStatus
default:
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field)
}
}
updatedUser, err := s.Store.UpdateUser(ctx, update)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
}
return convertUserFromStore(updatedUser), nil
}
func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserRequest) (*emptypb.Empty, error) {
userID, err := ExtractUserIDFromName(request.Name)
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 user: %v", err)
}
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
ID: user.ID,
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete user: %v", err)
}
return &emptypb.Empty{}, nil
}
func getDefaultUserGeneralSetting() *v1pb.UserSetting_GeneralSetting {
return &v1pb.UserSetting_GeneralSetting{
Locale: "en",
MemoVisibility: "PRIVATE",
Theme: "",
}
}
func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUserSettingRequest) (*v1pb.UserSetting, error) {
// Parse resource name: users/{user}/settings/{setting}
userID, settingKey, err := ExtractUserIDAndSettingKeyFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid resource 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.Unauthenticated, "user not authenticated")
}
// Only allow user to get their own settings
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Convert setting key string to store enum
storeKey, err := convertSettingKeyToStore(settingKey)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid setting key: %v", err)
}
userSetting, err := s.Store.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &userID,
Key: storeKey,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
}
return convertUserSettingFromStore(userSetting, userID, storeKey), nil
}
func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.UpdateUserSettingRequest) (*v1pb.UserSetting, error) {
// Parse resource name: users/{user}/settings/{setting}
userID, settingKey, err := ExtractUserIDAndSettingKeyFromName(request.Setting.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid resource 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.Unauthenticated, "user not authenticated")
}
// Only allow user to update their own settings
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
}
// Convert setting key string to store enum
storeKey, err := convertSettingKeyToStore(settingKey)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid setting key: %v", err)
}
// Only GENERAL settings are supported via UpdateUserSetting
// Other setting types have dedicated service methods
if storeKey != storepb.UserSetting_GENERAL {
return nil, status.Errorf(codes.InvalidArgument, "setting type %s should not be updated via UpdateUserSetting", storeKey.String())
}
existingUserSetting, _ := s.Store.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &userID,
Key: storeKey,
})
generalSetting := &storepb.GeneralUserSetting{}
if existingUserSetting != nil {
// Start with existing general setting values
generalSetting = existingUserSetting.GetGeneral()
}
updatedGeneral := &v1pb.UserSetting_GeneralSetting{
MemoVisibility: generalSetting.GetMemoVisibility(),
Locale: generalSetting.GetLocale(),
Theme: generalSetting.GetTheme(),
}
// Apply updates for fields specified in the update mask
incomingGeneral := request.Setting.GetGeneralSetting()
for _, field := range request.UpdateMask.Paths {
switch field {
case "memoVisibility":
updatedGeneral.MemoVisibility = incomingGeneral.MemoVisibility
case "theme":
updatedGeneral.Theme = incomingGeneral.Theme
case "locale":
updatedGeneral.Locale = incomingGeneral.Locale
default:
// Ignore unsupported fields
}
}
// Create the updated setting
updatedSetting := &v1pb.UserSetting{
Name: request.Setting.Name,
Value: &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: updatedGeneral,
},
}
// Convert API setting to store setting
storeSetting, err := convertUserSettingToStore(updatedSetting, userID, storeKey)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to convert setting: %v", err)
}
// Upsert the setting
if _, err := s.Store.UpsertUserSetting(ctx, storeSetting); err != nil {
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
}
return s.GetUserSetting(ctx, &v1pb.GetUserSettingRequest{Name: request.Setting.Name})
}
func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListUserSettingsRequest) (*v1pb.ListUserSettingsResponse, error) {
userID, err := ExtractUserIDFromName(request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent 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.Unauthenticated, "user not authenticated")
}
// Only allow user to list their own settings
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
userSettings, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{
UserID: &userID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list user settings: %v", err)
}
settings := make([]*v1pb.UserSetting, 0, len(userSettings))
for _, storeSetting := range userSettings {
apiSetting := convertUserSettingFromStore(storeSetting, userID, storeSetting.Key)
if apiSetting != nil {
settings = append(settings, apiSetting)
}
}
// If no general setting exists, add a default one
hasGeneral := false
for _, setting := range settings {
if setting.GetGeneralSetting() != nil {
hasGeneral = true
break
}
}
if !hasGeneral {
defaultGeneral := &v1pb.UserSetting{
Name: fmt.Sprintf("users/%d/settings/general", userID),
Value: &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: getDefaultUserGeneralSetting(),
},
}
settings = append([]*v1pb.UserSetting{defaultGeneral}, settings...)
}
response := &v1pb.ListUserSettingsResponse{
Settings: settings,
TotalSize: int32(len(settings)),
}
return response, nil
}
// ListUserAccessTokens retrieves all Personal Access Tokens (PATs) for a user.
//
// Personal Access Tokens are used for:
// - Mobile app authentication
// - CLI tool authentication
// - API client authentication
// - Any programmatic access requiring Bearer token auth
//
// Security:
// - Only the token owner can list their tokens
// - Returns full token strings (so users can manage/revoke them)
// - Invalid or expired tokens are filtered out
//
// Authentication: Required (session cookie or access token)
// Authorization: User can only list their own tokens.
func (s *APIV1Service) ListUserAccessTokens(ctx context.Context, request *v1pb.ListUserAccessTokensRequest) (*v1pb.ListUserAccessTokensResponse, 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.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
}
accessTokens := []*v1pb.UserAccessToken{}
for _, userAccessToken := range userAccessTokens {
claims := &ClaimsMessage{}
_, err := jwt.ParseWithClaims(userAccessToken.AccessToken, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == "v1" {
return []byte(s.Secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
// If the access token is invalid or expired, just ignore it.
continue
}
accessTokenResponse := &v1pb.UserAccessToken{
Name: fmt.Sprintf("users/%d/accessTokens/%s", userID, userAccessToken.AccessToken),
AccessToken: userAccessToken.AccessToken,
Description: userAccessToken.Description,
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
}
if claims.ExpiresAt != nil {
accessTokenResponse.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
}
accessTokens = append(accessTokens, accessTokenResponse)
}
// Sort by issued time in descending order.
slices.SortFunc(accessTokens, func(i, j *v1pb.UserAccessToken) int {
return int(i.IssuedAt.Seconds - j.IssuedAt.Seconds)
})
response := &v1pb.ListUserAccessTokensResponse{
AccessTokens: accessTokens,
}
return response, nil
}
// CreateUserAccessToken creates a new Personal Access Token (PAT) for a user.
//
// Use cases:
// - User manually creates token in settings for mobile app
// - User creates token for CLI tool
// - User creates token for third-party integration
//
// Token properties:
// - JWT format signed with server secret
// - Contains user ID and username in claims
// - Optional expiration time (can be never-expiring)
// - User-provided description for identification
//
// Security considerations:
// - Full token is only shown ONCE (in this response)
// - User should copy and store it securely
// - Token can be revoked by deleting it from settings
//
// Authentication: Required (session cookie or access token)
// Authorization: User can only create tokens for themselves.
func (s *APIV1Service) CreateUserAccessToken(ctx context.Context, request *v1pb.CreateUserAccessTokenRequest) (*v1pb.UserAccessToken, 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.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
expiresAt := time.Time{}
if request.AccessToken.ExpiresAt != nil {
expiresAt = request.AccessToken.ExpiresAt.AsTime()
}
accessToken, err := GenerateAccessToken(currentUser.Username, currentUser.ID, expiresAt, []byte(s.Secret))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
}
claims := &ClaimsMessage{}
_, err = jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == "v1" {
return []byte(s.Secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to parse access token: %v", err)
}
// Upsert the access token to user setting store.
if err := s.UpsertAccessTokenToStore(ctx, currentUser, accessToken, request.AccessToken.Description); err != nil {
return nil, status.Errorf(codes.Internal, "failed to upsert access token to store: %v", err)
}
userAccessToken := &v1pb.UserAccessToken{
Name: fmt.Sprintf("users/%d/accessTokens/%s", userID, accessToken),
AccessToken: accessToken,
Description: request.AccessToken.Description,
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
}
if claims.ExpiresAt != nil {
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
}
return userAccessToken, nil
}
// DeleteUserAccessToken revokes a Personal Access Token.
//
// This endpoint:
// 1. Removes the token from the user's access tokens list
// 2. Immediately invalidates the token (subsequent API calls with it will fail)
//
// Use cases:
// - User revokes a compromised token
// - User removes token for unused app/device
// - User cleans up old tokens
//
// Authentication: Required (session cookie or access token)
// Authorization: User can only delete their own tokens.
func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *v1pb.DeleteUserAccessTokenRequest) (*emptypb.Empty, error) {
// Extract user ID from the access token resource name
// Format: users/{user}/accessTokens/{access_token}
parts := strings.Split(request.Name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "accessTokens" {
return nil, status.Errorf(codes.InvalidArgument, "invalid access token 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)
}
accessTokenToDelete := 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.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, currentUser.ID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
}
updatedUserAccessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
for _, userAccessToken := range userAccessTokens {
if userAccessToken.AccessToken == accessTokenToDelete {
continue
}
updatedUserAccessTokens = append(updatedUserAccessTokens, userAccessToken)
}
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: currentUser.ID,
Key: storepb.UserSetting_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokens{
AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: updatedUserAccessTokens,
},
},
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
}
return &emptypb.Empty{}, nil
}
// ListUserSessions retrieves all active sessions for a user.
//
// Sessions represent active browser logins. Each session includes:
// - session_id: Unique identifier
// - create_time: When the session was created
// - last_accessed_time: Last API call time (for sliding expiration)
// - client_info: Device details (browser, OS, IP address, device type)
//
// Use cases:
// - User reviews where they're logged in
// - User identifies suspicious login attempts
// - User prepares to revoke specific sessions
//
// Authentication: Required (session cookie or access token)
// Authorization: User can only list their own sessions.
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.Unauthenticated, "user not authenticated")
}
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,
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,
}
}
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
}
// RevokeUserSession terminates a specific session for a user.
//
// This endpoint:
// 1. Removes the session from the user's sessions list
// 2. Immediately invalidates the session
// 3. Forces the device to re-login on next request
//
// Use cases:
// - User logs out from a specific device (e.g., "Log out my phone")
// - User removes suspicious/unknown session
// - User logs out from all devices except current one
//
// Note: This is different from DeleteSession (logout current session).
// This endpoint allows revoking ANY session, not just the current one.
//
// Authentication: Required (session cookie or access token)
// Authorization: User can only revoke their own sessions.
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.Unauthenticated, "user not authenticated")
}
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
}
// UpsertUserSession adds or updates 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(),
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 {
return errors.Wrap(err, "failed to get user access tokens")
}
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
AccessToken: accessToken,
Description: description,
}
userAccessTokens = append(userAccessTokens, &userAccessToken)
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokens{
AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: userAccessTokens,
},
},
}); err != nil {
return errors.Wrap(err, "failed to upsert user setting")
}
return nil
}
func (s *APIV1Service) ListUserWebhooks(ctx context.Context, request *v1pb.ListUserWebhooksRequest) (*v1pb.ListUserWebhooksResponse, error) {
userID, err := ExtractUserIDFromName(request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %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.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
webhooks, err := s.Store.GetUserWebhooks(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user webhooks: %v", err)
}
userWebhooks := make([]*v1pb.UserWebhook, 0, len(webhooks))
for _, webhook := range webhooks {
userWebhooks = append(userWebhooks, convertUserWebhookFromUserSetting(webhook, userID))
}
return &v1pb.ListUserWebhooksResponse{
Webhooks: userWebhooks,
}, nil
}
func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.CreateUserWebhookRequest) (*v1pb.UserWebhook, error) {
userID, err := ExtractUserIDFromName(request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %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.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if request.Webhook.Url == "" {
return nil, status.Errorf(codes.InvalidArgument, "webhook URL is required")
}
webhookID := generateUserWebhookID()
webhook := &storepb.WebhooksUserSetting_Webhook{
Id: webhookID,
Title: request.Webhook.DisplayName,
Url: strings.TrimSpace(request.Webhook.Url),
}
err = s.Store.AddUserWebhook(ctx, userID, webhook)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create webhook: %v", err)
}
return convertUserWebhookFromUserSetting(webhook, userID), nil
}
func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.UpdateUserWebhookRequest) (*v1pb.UserWebhook, error) {
if request.Webhook == nil {
return nil, status.Errorf(codes.InvalidArgument, "webhook is required")
}
webhookID, userID, err := parseUserWebhookName(request.Webhook.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid webhook 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.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Get existing webhooks
webhooks, err := s.Store.GetUserWebhooks(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user webhooks: %v", err)
}
// Find the webhook to update
var targetWebhook *storepb.WebhooksUserSetting_Webhook
for _, webhook := range webhooks {
if webhook.Id == webhookID {
targetWebhook = webhook
break
}
}
if targetWebhook == nil {
return nil, status.Errorf(codes.NotFound, "webhook not found")
}
// Update the webhook
updatedWebhook := &storepb.WebhooksUserSetting_Webhook{
Id: webhookID,
Title: targetWebhook.Title,
Url: targetWebhook.Url,
}
if request.UpdateMask != nil {
for _, path := range request.UpdateMask.Paths {
switch path {
case "url":
if request.Webhook.Url != "" {
updatedWebhook.Url = strings.TrimSpace(request.Webhook.Url)
}
case "display_name":
updatedWebhook.Title = request.Webhook.DisplayName
default:
// Ignore unsupported fields
}
}
} else {
// If no update mask is provided, update all fields
if request.Webhook.Url != "" {
updatedWebhook.Url = strings.TrimSpace(request.Webhook.Url)
}
updatedWebhook.Title = request.Webhook.DisplayName
}
err = s.Store.UpdateUserWebhook(ctx, userID, updatedWebhook)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update webhook: %v", err)
}
return convertUserWebhookFromUserSetting(updatedWebhook, userID), nil
}
func (s *APIV1Service) DeleteUserWebhook(ctx context.Context, request *v1pb.DeleteUserWebhookRequest) (*emptypb.Empty, error) {
webhookID, userID, err := parseUserWebhookName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid webhook 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.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Get existing webhooks to verify the webhook exists
webhooks, err := s.Store.GetUserWebhooks(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user webhooks: %v", err)
}
// Check if webhook exists
found := false
for _, webhook := range webhooks {
if webhook.Id == webhookID {
found = true
break
}
}
if !found {
return nil, status.Errorf(codes.NotFound, "webhook not found")
}
err = s.Store.RemoveUserWebhook(ctx, userID, webhookID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete webhook: %v", err)
}
return &emptypb.Empty{}, nil
}
// Helper functions for webhook operations
// generateUserWebhookID generates a unique ID for user webhooks.
func generateUserWebhookID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
// parseUserWebhookName parses a webhook name and returns the webhook ID and user ID.
// Format: users/{user}/webhooks/{webhook}.
func parseUserWebhookName(name string) (string, int32, error) {
parts := strings.Split(name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "webhooks" {
return "", 0, errors.New("invalid webhook name format")
}
userID, err := strconv.ParseInt(parts[1], 10, 32)
if err != nil {
return "", 0, errors.New("invalid user ID in webhook name")
}
return parts[3], int32(userID), nil
}
// convertUserWebhookFromUserSetting converts a storepb webhook to a v1pb UserWebhook.
func convertUserWebhookFromUserSetting(webhook *storepb.WebhooksUserSetting_Webhook, userID int32) *v1pb.UserWebhook {
return &v1pb.UserWebhook{
Name: fmt.Sprintf("users/%d/webhooks/%s", userID, webhook.Id),
Url: webhook.Url,
DisplayName: webhook.Title,
// Note: create_time and update_time are not available in the user setting webhook structure
// This is a limitation of storing webhooks in user settings vs the dedicated webhook table
}
}
func convertUserFromStore(user *store.User) *v1pb.User {
userpb := &v1pb.User{
Name: fmt.Sprintf("%s%d", UserNamePrefix, user.ID),
State: convertStateFromStore(user.RowStatus),
CreateTime: timestamppb.New(time.Unix(user.CreatedTs, 0)),
UpdateTime: timestamppb.New(time.Unix(user.UpdatedTs, 0)),
Role: convertUserRoleFromStore(user.Role),
Username: user.Username,
Email: user.Email,
DisplayName: user.Nickname,
AvatarUrl: user.AvatarURL,
Description: user.Description,
}
// Use the avatar URL instead of raw base64 image data to reduce the response size.
if user.AvatarURL != "" {
// Check if avatar url is base64 format.
_, _, err := extractImageInfo(user.AvatarURL)
if err == nil {
userpb.AvatarUrl = fmt.Sprintf("/api/v1/%s/avatar", userpb.Name)
} else {
userpb.AvatarUrl = user.AvatarURL
}
}
return userpb
}
func convertUserRoleFromStore(role store.Role) v1pb.User_Role {
switch role {
case store.RoleHost:
return v1pb.User_HOST
case store.RoleAdmin:
return v1pb.User_ADMIN
case store.RoleUser:
return v1pb.User_USER
default:
return v1pb.User_ROLE_UNSPECIFIED
}
}
func convertUserRoleToStore(role v1pb.User_Role) store.Role {
switch role {
case v1pb.User_HOST:
return store.RoleHost
case v1pb.User_ADMIN:
return store.RoleAdmin
default:
return store.RoleUser
}
}
func extractImageInfo(dataURI string) (string, string, error) {
dataURIRegex := regexp.MustCompile(`^data:(?P<type>.+);base64,(?P<base64>.+)`)
matches := dataURIRegex.FindStringSubmatch(dataURI)
if len(matches) != 3 {
return "", "", errors.New("Invalid data URI format")
}
imageType := matches[1]
base64Data := matches[2]
return imageType, base64Data, nil
}
// Helper functions for user settings
// ExtractUserIDAndSettingKeyFromName extracts user ID and setting key from resource name.
// e.g., "users/123/settings/general" -> 123, "general".
func ExtractUserIDAndSettingKeyFromName(name string) (int32, string, error) {
// Expected format: users/{user}/settings/{setting}
parts := strings.Split(name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "settings" {
return 0, "", errors.Errorf("invalid resource name format: %s", name)
}
userID, err := util.ConvertStringToInt32(parts[1])
if err != nil {
return 0, "", errors.Errorf("invalid user ID: %s", parts[1])
}
settingKey := parts[3]
return userID, settingKey, nil
}
// convertSettingKeyToStore converts API setting key to store enum.
func convertSettingKeyToStore(key string) (storepb.UserSetting_Key, error) {
switch key {
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_GENERAL)]:
return storepb.UserSetting_GENERAL, nil
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_SESSIONS)]:
return storepb.UserSetting_SESSIONS, nil
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_ACCESS_TOKENS)]:
return storepb.UserSetting_ACCESS_TOKENS, nil
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_WEBHOOKS)]:
return storepb.UserSetting_WEBHOOKS, nil
default:
return storepb.UserSetting_KEY_UNSPECIFIED, errors.Errorf("unknown setting key: %s", key)
}
}
// convertSettingKeyFromStore converts store enum to API setting key.
func convertSettingKeyFromStore(key storepb.UserSetting_Key) string {
switch key {
case storepb.UserSetting_GENERAL:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_GENERAL)]
case storepb.UserSetting_SESSIONS:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_SESSIONS)]
case storepb.UserSetting_ACCESS_TOKENS:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_ACCESS_TOKENS)]
case storepb.UserSetting_SHORTCUTS:
return "SHORTCUTS" // Not defined in API proto
case storepb.UserSetting_WEBHOOKS:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_WEBHOOKS)]
default:
return "unknown"
}
}
// convertUserSettingFromStore converts store UserSetting to API UserSetting.
func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32, key storepb.UserSetting_Key) *v1pb.UserSetting {
if storeSetting == nil {
// Return default setting if none exists
settingKey := convertSettingKeyFromStore(key)
setting := &v1pb.UserSetting{
Name: fmt.Sprintf("users/%d/settings/%s", userID, settingKey),
}
switch key {
case storepb.UserSetting_SESSIONS:
setting.Value = &v1pb.UserSetting_SessionsSetting_{
SessionsSetting: &v1pb.UserSetting_SessionsSetting{
Sessions: []*v1pb.UserSession{},
},
}
case storepb.UserSetting_ACCESS_TOKENS:
setting.Value = &v1pb.UserSetting_AccessTokensSetting_{
AccessTokensSetting: &v1pb.UserSetting_AccessTokensSetting{
AccessTokens: []*v1pb.UserAccessToken{},
},
}
case storepb.UserSetting_WEBHOOKS:
setting.Value = &v1pb.UserSetting_WebhooksSetting_{
WebhooksSetting: &v1pb.UserSetting_WebhooksSetting{
Webhooks: []*v1pb.UserWebhook{},
},
}
default:
// Default to general setting
setting.Value = &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: getDefaultUserGeneralSetting(),
}
}
return setting
}
settingKey := convertSettingKeyFromStore(storeSetting.Key)
setting := &v1pb.UserSetting{
Name: fmt.Sprintf("users/%d/settings/%s", userID, settingKey),
}
switch storeSetting.Key {
case storepb.UserSetting_GENERAL:
if general := storeSetting.GetGeneral(); general != nil {
setting.Value = &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: &v1pb.UserSetting_GeneralSetting{
Locale: general.Locale,
MemoVisibility: general.MemoVisibility,
Theme: general.Theme,
},
}
} else {
setting.Value = &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: getDefaultUserGeneralSetting(),
}
}
case storepb.UserSetting_SESSIONS:
sessions := storeSetting.GetSessions()
apiSessions := make([]*v1pb.UserSession, 0, len(sessions.Sessions))
for _, session := range sessions.Sessions {
apiSession := &v1pb.UserSession{
Name: fmt.Sprintf("users/%d/sessions/%s", userID, session.SessionId),
SessionId: session.SessionId,
CreateTime: session.CreateTime,
LastAccessedTime: session.LastAccessedTime,
ClientInfo: &v1pb.UserSession_ClientInfo{
UserAgent: session.ClientInfo.UserAgent,
IpAddress: session.ClientInfo.IpAddress,
DeviceType: session.ClientInfo.DeviceType,
Os: session.ClientInfo.Os,
Browser: session.ClientInfo.Browser,
},
}
apiSessions = append(apiSessions, apiSession)
}
setting.Value = &v1pb.UserSetting_SessionsSetting_{
SessionsSetting: &v1pb.UserSetting_SessionsSetting{
Sessions: apiSessions,
},
}
case storepb.UserSetting_ACCESS_TOKENS:
accessTokens := storeSetting.GetAccessTokens()
apiTokens := make([]*v1pb.UserAccessToken, 0, len(accessTokens.AccessTokens))
for _, token := range accessTokens.AccessTokens {
apiToken := &v1pb.UserAccessToken{
Name: fmt.Sprintf("users/%d/accessTokens/%s", userID, token.AccessToken),
AccessToken: token.AccessToken,
Description: token.Description,
}
apiTokens = append(apiTokens, apiToken)
}
setting.Value = &v1pb.UserSetting_AccessTokensSetting_{
AccessTokensSetting: &v1pb.UserSetting_AccessTokensSetting{
AccessTokens: apiTokens,
},
}
case storepb.UserSetting_WEBHOOKS:
webhooks := storeSetting.GetWebhooks()
apiWebhooks := make([]*v1pb.UserWebhook, 0, len(webhooks.Webhooks))
for _, webhook := range webhooks.Webhooks {
apiWebhook := &v1pb.UserWebhook{
Name: fmt.Sprintf("users/%d/webhooks/%s", userID, webhook.Id),
Url: webhook.Url,
DisplayName: webhook.Title,
}
apiWebhooks = append(apiWebhooks, apiWebhook)
}
setting.Value = &v1pb.UserSetting_WebhooksSetting_{
WebhooksSetting: &v1pb.UserSetting_WebhooksSetting{
Webhooks: apiWebhooks,
},
}
default:
// Default to general setting if unknown key
setting.Value = &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: getDefaultUserGeneralSetting(),
}
}
return setting
}
// convertUserSettingToStore converts API UserSetting to store UserSetting.
func convertUserSettingToStore(apiSetting *v1pb.UserSetting, userID int32, key storepb.UserSetting_Key) (*storepb.UserSetting, error) {
storeSetting := &storepb.UserSetting{
UserId: userID,
Key: key,
}
switch key {
case storepb.UserSetting_GENERAL:
if general := apiSetting.GetGeneralSetting(); general != nil {
storeSetting.Value = &storepb.UserSetting_General{
General: &storepb.GeneralUserSetting{
Locale: general.Locale,
MemoVisibility: general.MemoVisibility,
Theme: general.Theme,
},
}
} else {
return nil, errors.Errorf("general setting is required")
}
case storepb.UserSetting_SESSIONS:
if sessions := apiSetting.GetSessionsSetting(); sessions != nil {
storeSessions := make([]*storepb.SessionsUserSetting_Session, 0, len(sessions.Sessions))
for _, session := range sessions.Sessions {
storeSession := &storepb.SessionsUserSetting_Session{
SessionId: session.SessionId,
CreateTime: session.CreateTime,
LastAccessedTime: session.LastAccessedTime,
ClientInfo: &storepb.SessionsUserSetting_ClientInfo{
UserAgent: session.ClientInfo.UserAgent,
IpAddress: session.ClientInfo.IpAddress,
DeviceType: session.ClientInfo.DeviceType,
Os: session.ClientInfo.Os,
Browser: session.ClientInfo.Browser,
},
}
storeSessions = append(storeSessions, storeSession)
}
storeSetting.Value = &storepb.UserSetting_Sessions{
Sessions: &storepb.SessionsUserSetting{
Sessions: storeSessions,
},
}
} else {
return nil, errors.Errorf("sessions setting is required")
}
case storepb.UserSetting_ACCESS_TOKENS:
if accessTokens := apiSetting.GetAccessTokensSetting(); accessTokens != nil {
storeTokens := make([]*storepb.AccessTokensUserSetting_AccessToken, 0, len(accessTokens.AccessTokens))
for _, token := range accessTokens.AccessTokens {
storeToken := &storepb.AccessTokensUserSetting_AccessToken{
AccessToken: token.AccessToken,
Description: token.Description,
}
storeTokens = append(storeTokens, storeToken)
}
storeSetting.Value = &storepb.UserSetting_AccessTokens{
AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: storeTokens,
},
}
} else {
return nil, errors.Errorf("access tokens setting is required")
}
case storepb.UserSetting_WEBHOOKS:
if webhooks := apiSetting.GetWebhooksSetting(); webhooks != nil {
storeWebhooks := make([]*storepb.WebhooksUserSetting_Webhook, 0, len(webhooks.Webhooks))
for _, webhook := range webhooks.Webhooks {
storeWebhook := &storepb.WebhooksUserSetting_Webhook{
Id: extractWebhookIDFromName(webhook.Name),
Title: webhook.DisplayName,
Url: webhook.Url,
}
storeWebhooks = append(storeWebhooks, storeWebhook)
}
storeSetting.Value = &storepb.UserSetting_Webhooks{
Webhooks: &storepb.WebhooksUserSetting{
Webhooks: storeWebhooks,
},
}
} else {
return nil, errors.Errorf("webhooks setting is required")
}
default:
return nil, errors.Errorf("unsupported setting key: %v", key)
}
return storeSetting, nil
}
// extractWebhookIDFromName extracts webhook ID from resource name.
// e.g., "users/123/webhooks/webhook-id" -> "webhook-id".
func extractWebhookIDFromName(name string) string {
parts := strings.Split(name, "/")
if len(parts) >= 4 && parts[0] == "users" && parts[2] == "webhooks" {
return parts[3]
}
return ""
}
// extractUsernameFromFilter extracts username from the filter string using CEL.
// Supported filter format: "username == 'steven'"
// Returns the username value and an error if the filter format is invalid.
func extractUsernameFromFilter(filterStr string) (string, error) {
filterStr = strings.TrimSpace(filterStr)
if filterStr == "" {
return "", nil
}
// Create CEL environment with username variable
env, err := cel.NewEnv(
cel.Variable("username", cel.StringType),
)
if err != nil {
return "", errors.Wrap(err, "failed to create CEL environment")
}
// Parse and check the filter expression
celAST, issues := env.Compile(filterStr)
if issues != nil && issues.Err() != nil {
return "", errors.Wrapf(issues.Err(), "invalid filter expression: %s", filterStr)
}
// Extract username from the AST
username, err := extractUsernameFromAST(celAST.NativeRep().Expr())
if err != nil {
return "", err
}
return username, nil
}
// extractUsernameFromAST extracts the username value from a CEL AST expression.
func extractUsernameFromAST(expr ast.Expr) (string, error) {
if expr == nil {
return "", errors.New("empty expression")
}
// Check if this is a call expression (for ==, !=, etc.)
if expr.Kind() != ast.CallKind {
return "", errors.New("filter must be a comparison expression (e.g., username == 'value')")
}
call := expr.AsCall()
// We only support == operator
if call.FunctionName() != "_==_" {
return "", errors.Errorf("unsupported operator: %s (only '==' is supported)", call.FunctionName())
}
// The call should have exactly 2 arguments
args := call.Args()
if len(args) != 2 {
return "", errors.New("invalid comparison expression")
}
// Try to extract username from either left or right side
if username, ok := extractUsernameFromComparison(args[0], args[1]); ok {
return username, nil
}
if username, ok := extractUsernameFromComparison(args[1], args[0]); ok {
return username, nil
}
return "", errors.New("filter must compare 'username' field with a string constant")
}
// extractUsernameFromComparison tries to extract username value if left is 'username' ident and right is a string constant.
func extractUsernameFromComparison(left, right ast.Expr) (string, bool) {
// Check if left side is 'username' identifier
if left.Kind() != ast.IdentKind {
return "", false
}
ident := left.AsIdent()
if ident != "username" {
return "", false
}
// Right side should be a constant string
if right.Kind() != ast.LiteralKind {
return "", false
}
literal := right.AsLiteral()
// literal is a ref.Val, we need to get the Go value
str, ok := literal.Value().(string)
if !ok || str == "" {
return "", false
}
return str, true
}
// ListUserNotifications lists all notifications for a user.
// Notifications are backed by the inbox storage layer and represent activities
// that require user attention (e.g., memo comments).
func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.ListUserNotificationsRequest) (*v1pb.ListUserNotificationsResponse, error) {
userID, err := ExtractUserIDFromName(request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
// Verify the requesting user has permission to view these notifications
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Fetch inbox items from storage
// Filter at database level to only include MEMO_COMMENT notifications (ignore legacy VERSION_UPDATE entries)
memoCommentType := storepb.InboxMessage_MEMO_COMMENT
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ReceiverID: &userID,
MessageType: &memoCommentType,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list inboxes: %v", err)
}
// Convert storage layer inboxes to API notifications
notifications := []*v1pb.UserNotification{}
for _, inbox := range inboxes {
notification, err := s.convertInboxToUserNotification(ctx, inbox)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err)
}
notifications = append(notifications, notification)
}
return &v1pb.ListUserNotificationsResponse{
Notifications: notifications,
}, nil
}
// UpdateUserNotification updates a notification's status (e.g., marking as read/archived).
// Only the notification owner can update their notifications.
func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb.UpdateUserNotificationRequest) (*v1pb.UserNotification, error) {
if request.Notification == nil {
return nil, status.Errorf(codes.InvalidArgument, "notification is required")
}
notificationID, err := ExtractNotificationIDFromName(request.Notification.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid notification name: %v", err)
}
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
// Verify ownership before updating
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ID: &notificationID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err)
}
if len(inboxes) == 0 {
return nil, status.Errorf(codes.NotFound, "notification not found")
}
inbox := inboxes[0]
if inbox.ReceiverID != currentUser.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Build update request based on field mask
update := &store.UpdateInbox{
ID: notificationID,
}
for _, path := range request.UpdateMask.Paths {
switch path {
case "status":
// Convert API status enum to storage enum
var inboxStatus store.InboxStatus
switch request.Notification.Status {
case v1pb.UserNotification_UNREAD:
inboxStatus = store.UNREAD
case v1pb.UserNotification_ARCHIVED:
inboxStatus = store.ARCHIVED
default:
return nil, status.Errorf(codes.InvalidArgument, "invalid status")
}
update.Status = inboxStatus
default:
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", path)
}
}
updatedInbox, err := s.Store.UpdateInbox(ctx, update)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err)
}
notification, err := s.convertInboxToUserNotification(ctx, updatedInbox)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err)
}
return notification, nil
}
// DeleteUserNotification permanently deletes a notification.
// Only the notification owner can delete their notifications.
func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb.DeleteUserNotificationRequest) (*emptypb.Empty, error) {
notificationID, err := ExtractNotificationIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid notification name: %v", err)
}
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
// Verify ownership before deletion
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ID: &notificationID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err)
}
if len(inboxes) == 0 {
return nil, status.Errorf(codes.NotFound, "notification not found")
}
inbox := inboxes[0]
if inbox.ReceiverID != currentUser.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if err := s.Store.DeleteInbox(ctx, &store.DeleteInbox{
ID: notificationID,
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete inbox: %v", err)
}
return &emptypb.Empty{}, nil
}
// convertInboxToUserNotification converts a storage-layer inbox to an API notification.
// This handles the mapping between the internal inbox representation and the public API.
func (*APIV1Service) convertInboxToUserNotification(_ context.Context, inbox *store.Inbox) (*v1pb.UserNotification, error) {
notification := &v1pb.UserNotification{
Name: fmt.Sprintf("users/%d/notifications/%d", inbox.ReceiverID, inbox.ID),
Sender: fmt.Sprintf("%s%d", UserNamePrefix, inbox.SenderID),
CreateTime: timestamppb.New(time.Unix(inbox.CreatedTs, 0)),
}
// Convert status from storage enum to API enum
switch inbox.Status {
case store.UNREAD:
notification.Status = v1pb.UserNotification_UNREAD
case store.ARCHIVED:
notification.Status = v1pb.UserNotification_ARCHIVED
default:
notification.Status = v1pb.UserNotification_STATUS_UNSPECIFIED
}
// Extract notification type and activity ID from inbox message
if inbox.Message != nil {
switch inbox.Message.Type {
case storepb.InboxMessage_MEMO_COMMENT:
notification.Type = v1pb.UserNotification_MEMO_COMMENT
default:
notification.Type = v1pb.UserNotification_TYPE_UNSPECIFIED
}
if inbox.Message.ActivityId != nil {
notification.ActivityId = inbox.Message.ActivityId
}
}
return notification, nil
}
// ExtractNotificationIDFromName extracts the notification ID from a resource name.
// Expected format: users/{user_id}/notifications/{notification_id}.
func ExtractNotificationIDFromName(name string) (int32, error) {
pattern := regexp.MustCompile(`^users/(\d+)/notifications/(\d+)$`)
matches := pattern.FindStringSubmatch(name)
if len(matches) != 3 {
return 0, errors.Errorf("invalid notification name: %s", name)
}
id, err := strconv.Atoi(matches[2])
if err != nil {
return 0, errors.Errorf("invalid notification id: %s", matches[2])
}
return int32(id), nil
}