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
323 lines
12 KiB
Go
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
|
|
}
|