Files
wehub-resource-sync 4cddfcf2f3
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
chore: import upstream snapshot with attribution
2026-07-13 12:02:24 +08:00

323 lines
12 KiB
Go

package v1
import (
"context"
"strings"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/notification"
"github.com/usememos/memos/store"
)
const (
maxTranscriptionConfigModelLength = 256
maxTranscriptionConfigLanguageLength = 32
maxTranscriptionConfigPromptLength = 4096
maxBatchGetInstanceSettings = 100
)
type instanceSettingCaller struct {
user *store.User
loaded bool
}
func (c *instanceSettingCaller) currentUser(ctx context.Context, service *APIV1Service) (*store.User, error) {
if c.loaded {
return c.user, nil
}
user, err := service.fetchCurrentUser(ctx)
if err != nil {
return nil, err
}
c.user = user
c.loaded = true
return c.user, nil
}
// GetInstanceProfile returns the instance profile.
func (s *APIV1Service) GetInstanceProfile(ctx context.Context, _ *v1pb.GetInstanceProfileRequest) (*v1pb.InstanceProfile, error) {
admin, err := s.GetInstanceAdmin(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get instance admin: %v", err)
}
// needs_setup reflects whether the instance has any users at all, which is
// the real signal for first-run setup. It is deliberately independent of the
// admin lookup: an instance that has lost its admins still has users and must
// not be treated as a fresh install.
limitOne := 1
users, err := s.Store.ListUsers(ctx, &store.FindUser{Limit: &limitOne})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
}
instanceProfile := &v1pb.InstanceProfile{
Version: s.Profile.Version,
Demo: s.Profile.Demo,
InstanceUrl: s.Profile.InstanceURL,
Admin: admin, // for display only; may be nil even on a populated instance
Commit: s.Profile.Commit,
NeedsSetup: len(users) == 0,
}
return instanceProfile, nil
}
func (s *APIV1Service) GetInstanceSetting(ctx context.Context, request *v1pb.GetInstanceSettingRequest) (*v1pb.InstanceSetting, error) {
return s.getInstanceSettingByName(ctx, request.Name, &instanceSettingCaller{})
}
// BatchGetInstanceSettings returns multiple instance settings in request order.
func (s *APIV1Service) BatchGetInstanceSettings(ctx context.Context, request *v1pb.BatchGetInstanceSettingsRequest) (*v1pb.BatchGetInstanceSettingsResponse, error) {
if len(request.Names) > maxBatchGetInstanceSettings {
return nil, status.Errorf(codes.InvalidArgument, "too many instance setting names (max %d)", maxBatchGetInstanceSettings)
}
caller := &instanceSettingCaller{}
settings := make([]*v1pb.InstanceSetting, 0, len(request.Names))
for _, name := range request.Names {
setting, err := s.getInstanceSettingByName(ctx, name, caller)
if err != nil {
return nil, err
}
settings = append(settings, setting)
}
return &v1pb.BatchGetInstanceSettingsResponse{Settings: settings}, nil
}
func (s *APIV1Service) getInstanceSettingByName(ctx context.Context, name string, caller *instanceSettingCaller) (*v1pb.InstanceSetting, error) {
instanceSettingKeyString, err := ExtractInstanceSettingKeyFromName(name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid instance setting name: %v", err)
}
instanceSettingKey := storepb.InstanceSettingKey(storepb.InstanceSettingKey_value[instanceSettingKeyString])
// Get instance setting from store with default value.
switch instanceSettingKey {
case storepb.InstanceSettingKey_BASIC:
_, err = s.Store.GetInstanceBasicSetting(ctx)
case storepb.InstanceSettingKey_GENERAL:
_, err = s.Store.GetInstanceGeneralSetting(ctx)
case storepb.InstanceSettingKey_MEMO_RELATED:
_, err = s.Store.GetInstanceMemoRelatedSetting(ctx)
case storepb.InstanceSettingKey_STORAGE:
_, err = s.Store.GetInstanceStorageSetting(ctx)
case storepb.InstanceSettingKey_TAGS:
_, err = s.Store.GetInstanceTagsSetting(ctx)
case storepb.InstanceSettingKey_NOTIFICATION:
_, err = s.Store.GetInstanceNotificationSetting(ctx)
case storepb.InstanceSettingKey_AI:
_, err = s.Store.GetInstanceAISetting(ctx)
default:
return nil, status.Errorf(codes.InvalidArgument, "unsupported instance setting key: %v", instanceSettingKey)
}
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get instance setting: %v", err)
}
instanceSetting, err := s.Store.GetInstanceSetting(ctx, &store.FindInstanceSetting{
Name: instanceSettingKey.String(),
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get instance setting: %v", err)
}
if instanceSetting == nil {
return nil, status.Errorf(codes.NotFound, "instance setting not found")
}
// Storage and notification settings contain credentials; restrict to admins only.
if instanceSetting.Key == storepb.InstanceSettingKey_STORAGE ||
instanceSetting.Key == storepb.InstanceSettingKey_NOTIFICATION {
user, err := caller.currentUser(ctx, s)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if user.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
}
isAdminCaller := false
if instanceSetting.Key == storepb.InstanceSettingKey_AI {
user, err := caller.currentUser(ctx, s)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
isAdminCaller = user.Role == store.RoleAdmin
}
result := convertInstanceSettingFromStore(instanceSetting)
if instanceSetting.Key == storepb.InstanceSettingKey_AI && !isAdminCaller {
// Non-admin callers only need transcription.provider_id to gate the
// editor's Transcribe button. Model / language / prompt are
// admin-entered defaults that may contain proprietary glossary terms,
// so they are redacted from non-admin responses.
if ai := result.GetAiSetting(); ai != nil && ai.Transcription != nil {
ai.Transcription.Model = ""
ai.Transcription.Language = ""
ai.Transcription.Prompt = ""
}
}
return result, nil
}
func (s *APIV1Service) UpdateInstanceSetting(ctx context.Context, request *v1pb.UpdateInstanceSettingRequest) (*v1pb.InstanceSetting, error) {
user, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if user.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// TODO: Apply update_mask if specified
_ = request.UpdateMask
if err := validateInstanceSetting(request.Setting); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid instance setting: %v", err)
}
updateSetting := convertInstanceSettingToStore(request.Setting)
// Preserve write-only credential fields when the caller sends an empty value.
// An empty string means "no change", not "clear the credential".
switch updateSetting.Key {
case storepb.InstanceSettingKey_NOTIFICATION:
if notif := updateSetting.GetNotificationSetting(); notif != nil && notif.Email != nil && notif.Email.SmtpPassword == "" {
existing, err := s.Store.GetInstanceNotificationSetting(ctx)
if err == nil && existing != nil && existing.Email != nil {
if existing.Email.SmtpPassword != "" && !sameSMTPConnectionIdentity(notif.Email, existing.Email) {
return nil, status.Errorf(codes.InvalidArgument, "smtp password is required when changing SMTP host, port, username, or encryption settings")
}
notif.Email.SmtpPassword = existing.Email.SmtpPassword
}
}
case storepb.InstanceSettingKey_STORAGE:
if storage := updateSetting.GetStorageSetting(); storage != nil && storage.S3Config != nil && storage.S3Config.AccessKeySecret == "" {
existing, err := s.Store.GetInstanceStorageSetting(ctx)
if err == nil && existing != nil && existing.S3Config != nil {
storage.S3Config.AccessKeySecret = existing.S3Config.AccessKeySecret
}
}
case storepb.InstanceSettingKey_AI:
if err := s.prepareInstanceAISettingForUpdate(ctx, updateSetting.GetAiSetting()); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid AI setting: %v", err)
}
default:
// No credential preservation needed for other setting types.
}
instanceSetting, err := s.Store.UpsertInstanceSetting(ctx, updateSetting)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to upsert instance setting: %v", err)
}
return convertInstanceSettingFromStore(instanceSetting), nil
}
func (s *APIV1Service) TestInstanceEmailSetting(ctx context.Context, request *v1pb.TestInstanceEmailSettingRequest) (*emptypb.Empty, error) {
user, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if user.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
emailSetting, err := s.resolveTestEmailSetting(ctx, request.Email)
if err != nil {
return nil, err
}
recipientEmail := strings.TrimSpace(request.RecipientEmail)
if recipientEmail == "" {
recipientEmail = strings.TrimSpace(user.Email)
}
if recipientEmail == "" {
return nil, status.Errorf(codes.InvalidArgument, "recipient email is required")
}
if err := notification.ValidateEmailSetting(emailSetting); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid notification email setting: %v", err)
}
if err := notification.SendTestEmail(emailSetting, recipientEmail); err != nil {
return nil, status.Errorf(codes.Internal, "failed to send test email: %v. Check that the SMTP port matches encryption: Gmail uses port 587 with STARTTLS on and SSL/TLS off; port 465 requires SSL/TLS on", err)
}
return &emptypb.Empty{}, nil
}
func (s *APIV1Service) resolveTestEmailSetting(ctx context.Context, requestEmail *v1pb.InstanceSetting_NotificationSetting_EmailSetting) (*storepb.InstanceNotificationSetting_EmailSetting, error) {
if requestEmail == nil {
existing, err := s.Store.GetInstanceNotificationSetting(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get notification setting: %v", err)
}
return existing.GetEmail(), nil
}
emailSetting := convertInstanceNotificationSettingToStore(&v1pb.InstanceSetting_NotificationSetting{Email: requestEmail}).GetEmail()
if emailSetting.SmtpPassword != "" {
return emailSetting, nil
}
existing, err := s.Store.GetInstanceNotificationSetting(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get notification setting: %v", err)
}
existingEmail := existing.GetEmail()
if existingEmail == nil || existingEmail.SmtpPassword == "" {
return emailSetting, nil
}
if sameSMTPConnectionIdentity(emailSetting, existingEmail) {
emailSetting.SmtpPassword = existingEmail.SmtpPassword
return emailSetting, nil
}
return nil, status.Errorf(codes.InvalidArgument, "smtp password is required when changing SMTP host, port, username, or encryption settings")
}
func sameSMTPConnectionIdentity(setting, existing *storepb.InstanceNotificationSetting_EmailSetting) bool {
if setting == nil || existing == nil {
return false
}
return strings.TrimSpace(setting.SmtpHost) == strings.TrimSpace(existing.SmtpHost) &&
setting.SmtpPort == existing.SmtpPort &&
strings.TrimSpace(setting.SmtpUsername) == strings.TrimSpace(existing.SmtpUsername) &&
setting.UseTls == existing.UseTls &&
setting.UseSsl == existing.UseSsl
}
func (s *APIV1Service) GetInstanceAdmin(ctx context.Context) (*v1pb.User, error) {
adminUserType := store.RoleAdmin
user, err := s.Store.GetUser(ctx, &store.FindUser{
Role: &adminUserType,
})
if err != nil {
return nil, errors.Wrapf(err, "failed to find admin")
}
if user == nil {
return nil, nil
}
currentUser, _ := s.fetchCurrentUser(ctx)
return convertUserFromStore(user, currentUser), nil
}