0.29.1原版
This commit is contained in:
@@ -0,0 +1,836 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/pkg/errors"
|
||||
colorpb "google.golang.org/genproto/googleapis/type/color"
|
||||
"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)
|
||||
}
|
||||
|
||||
instanceProfile := &v1pb.InstanceProfile{
|
||||
Version: s.Profile.Version,
|
||||
Demo: s.Profile.Demo,
|
||||
InstanceUrl: s.Profile.InstanceURL,
|
||||
Admin: admin, // nil when not initialized
|
||||
Commit: s.Profile.Commit,
|
||||
}
|
||||
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 convertInstanceSettingFromStore(setting *storepb.InstanceSetting) *v1pb.InstanceSetting {
|
||||
instanceSetting := &v1pb.InstanceSetting{
|
||||
Name: fmt.Sprintf("instance/settings/%s", setting.Key.String()),
|
||||
}
|
||||
switch setting.Value.(type) {
|
||||
case *storepb.InstanceSetting_GeneralSetting:
|
||||
instanceSetting.Value = &v1pb.InstanceSetting_GeneralSetting_{
|
||||
GeneralSetting: convertInstanceGeneralSettingFromStore(setting.GetGeneralSetting()),
|
||||
}
|
||||
case *storepb.InstanceSetting_StorageSetting:
|
||||
instanceSetting.Value = &v1pb.InstanceSetting_StorageSetting_{
|
||||
StorageSetting: convertInstanceStorageSettingFromStore(setting.GetStorageSetting()),
|
||||
}
|
||||
case *storepb.InstanceSetting_MemoRelatedSetting:
|
||||
instanceSetting.Value = &v1pb.InstanceSetting_MemoRelatedSetting_{
|
||||
MemoRelatedSetting: convertInstanceMemoRelatedSettingFromStore(setting.GetMemoRelatedSetting()),
|
||||
}
|
||||
case *storepb.InstanceSetting_TagsSetting:
|
||||
instanceSetting.Value = &v1pb.InstanceSetting_TagsSetting_{
|
||||
TagsSetting: convertInstanceTagsSettingFromStore(setting.GetTagsSetting()),
|
||||
}
|
||||
case *storepb.InstanceSetting_NotificationSetting:
|
||||
instanceSetting.Value = &v1pb.InstanceSetting_NotificationSetting_{
|
||||
NotificationSetting: convertInstanceNotificationSettingFromStore(setting.GetNotificationSetting()),
|
||||
}
|
||||
case *storepb.InstanceSetting_AiSetting:
|
||||
instanceSetting.Value = &v1pb.InstanceSetting_AiSetting{
|
||||
AiSetting: convertInstanceAISettingFromStore(setting.GetAiSetting()),
|
||||
}
|
||||
default:
|
||||
// Leave Value unset for unsupported setting variants.
|
||||
}
|
||||
return instanceSetting
|
||||
}
|
||||
|
||||
func convertInstanceSettingToStore(setting *v1pb.InstanceSetting) *storepb.InstanceSetting {
|
||||
settingKeyString, _ := ExtractInstanceSettingKeyFromName(setting.Name)
|
||||
instanceSetting := &storepb.InstanceSetting{
|
||||
Key: storepb.InstanceSettingKey(storepb.InstanceSettingKey_value[settingKeyString]),
|
||||
Value: &storepb.InstanceSetting_GeneralSetting{
|
||||
GeneralSetting: convertInstanceGeneralSettingToStore(setting.GetGeneralSetting()),
|
||||
},
|
||||
}
|
||||
switch instanceSetting.Key {
|
||||
case storepb.InstanceSettingKey_GENERAL:
|
||||
instanceSetting.Value = &storepb.InstanceSetting_GeneralSetting{
|
||||
GeneralSetting: convertInstanceGeneralSettingToStore(setting.GetGeneralSetting()),
|
||||
}
|
||||
case storepb.InstanceSettingKey_STORAGE:
|
||||
instanceSetting.Value = &storepb.InstanceSetting_StorageSetting{
|
||||
StorageSetting: convertInstanceStorageSettingToStore(setting.GetStorageSetting()),
|
||||
}
|
||||
case storepb.InstanceSettingKey_MEMO_RELATED:
|
||||
instanceSetting.Value = &storepb.InstanceSetting_MemoRelatedSetting{
|
||||
MemoRelatedSetting: convertInstanceMemoRelatedSettingToStore(setting.GetMemoRelatedSetting()),
|
||||
}
|
||||
case storepb.InstanceSettingKey_TAGS:
|
||||
instanceSetting.Value = &storepb.InstanceSetting_TagsSetting{
|
||||
TagsSetting: convertInstanceTagsSettingToStore(setting.GetTagsSetting()),
|
||||
}
|
||||
case storepb.InstanceSettingKey_NOTIFICATION:
|
||||
instanceSetting.Value = &storepb.InstanceSetting_NotificationSetting{
|
||||
NotificationSetting: convertInstanceNotificationSettingToStore(setting.GetNotificationSetting()),
|
||||
}
|
||||
case storepb.InstanceSettingKey_AI:
|
||||
instanceSetting.Value = &storepb.InstanceSetting_AiSetting{
|
||||
AiSetting: convertInstanceAISettingToStore(setting.GetAiSetting()),
|
||||
}
|
||||
default:
|
||||
// Keep the default GeneralSetting value
|
||||
}
|
||||
return instanceSetting
|
||||
}
|
||||
|
||||
func convertInstanceGeneralSettingFromStore(setting *storepb.InstanceGeneralSetting) *v1pb.InstanceSetting_GeneralSetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
generalSetting := &v1pb.InstanceSetting_GeneralSetting{
|
||||
DisallowUserRegistration: setting.DisallowUserRegistration,
|
||||
DisallowPasswordAuth: setting.DisallowPasswordAuth,
|
||||
AdditionalScript: setting.AdditionalScript,
|
||||
AdditionalStyle: setting.AdditionalStyle,
|
||||
WeekStartDayOffset: setting.WeekStartDayOffset,
|
||||
DisallowChangeUsername: setting.DisallowChangeUsername,
|
||||
DisallowChangeNickname: setting.DisallowChangeNickname,
|
||||
}
|
||||
if setting.CustomProfile != nil {
|
||||
generalSetting.CustomProfile = &v1pb.InstanceSetting_GeneralSetting_CustomProfile{
|
||||
Title: setting.CustomProfile.Title,
|
||||
Description: setting.CustomProfile.Description,
|
||||
LogoUrl: setting.CustomProfile.LogoUrl,
|
||||
}
|
||||
}
|
||||
return generalSetting
|
||||
}
|
||||
|
||||
func convertInstanceGeneralSettingToStore(setting *v1pb.InstanceSetting_GeneralSetting) *storepb.InstanceGeneralSetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
generalSetting := &storepb.InstanceGeneralSetting{
|
||||
DisallowUserRegistration: setting.DisallowUserRegistration,
|
||||
DisallowPasswordAuth: setting.DisallowPasswordAuth,
|
||||
AdditionalScript: setting.AdditionalScript,
|
||||
AdditionalStyle: setting.AdditionalStyle,
|
||||
WeekStartDayOffset: setting.WeekStartDayOffset,
|
||||
DisallowChangeUsername: setting.DisallowChangeUsername,
|
||||
DisallowChangeNickname: setting.DisallowChangeNickname,
|
||||
}
|
||||
if setting.CustomProfile != nil {
|
||||
generalSetting.CustomProfile = &storepb.InstanceCustomProfile{
|
||||
Title: setting.CustomProfile.Title,
|
||||
Description: setting.CustomProfile.Description,
|
||||
LogoUrl: setting.CustomProfile.LogoUrl,
|
||||
}
|
||||
}
|
||||
return generalSetting
|
||||
}
|
||||
|
||||
func convertInstanceStorageSettingFromStore(settingpb *storepb.InstanceStorageSetting) *v1pb.InstanceSetting_StorageSetting {
|
||||
if settingpb == nil {
|
||||
return nil
|
||||
}
|
||||
setting := &v1pb.InstanceSetting_StorageSetting{
|
||||
StorageType: v1pb.InstanceSetting_StorageSetting_StorageType(settingpb.StorageType),
|
||||
FilepathTemplate: settingpb.FilepathTemplate,
|
||||
UploadSizeLimitMb: settingpb.UploadSizeLimitMb,
|
||||
}
|
||||
if settingpb.S3Config != nil {
|
||||
setting.S3Config = &v1pb.InstanceSetting_StorageSetting_S3Config{
|
||||
AccessKeyId: settingpb.S3Config.AccessKeyId,
|
||||
// AccessKeySecret is write-only: never returned in responses.
|
||||
Endpoint: settingpb.S3Config.Endpoint,
|
||||
Region: settingpb.S3Config.Region,
|
||||
Bucket: settingpb.S3Config.Bucket,
|
||||
UsePathStyle: settingpb.S3Config.UsePathStyle,
|
||||
}
|
||||
}
|
||||
return setting
|
||||
}
|
||||
|
||||
func convertInstanceStorageSettingToStore(setting *v1pb.InstanceSetting_StorageSetting) *storepb.InstanceStorageSetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
settingpb := &storepb.InstanceStorageSetting{
|
||||
StorageType: storepb.InstanceStorageSetting_StorageType(setting.StorageType),
|
||||
FilepathTemplate: setting.FilepathTemplate,
|
||||
UploadSizeLimitMb: setting.UploadSizeLimitMb,
|
||||
}
|
||||
if setting.S3Config != nil {
|
||||
settingpb.S3Config = &storepb.StorageS3Config{
|
||||
AccessKeyId: setting.S3Config.AccessKeyId,
|
||||
AccessKeySecret: setting.S3Config.AccessKeySecret,
|
||||
Endpoint: setting.S3Config.Endpoint,
|
||||
Region: setting.S3Config.Region,
|
||||
Bucket: setting.S3Config.Bucket,
|
||||
UsePathStyle: setting.S3Config.UsePathStyle,
|
||||
}
|
||||
}
|
||||
return settingpb
|
||||
}
|
||||
|
||||
func convertInstanceMemoRelatedSettingFromStore(setting *storepb.InstanceMemoRelatedSetting) *v1pb.InstanceSetting_MemoRelatedSetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
return &v1pb.InstanceSetting_MemoRelatedSetting{
|
||||
ContentLengthLimit: setting.ContentLengthLimit,
|
||||
EnableDoubleClickEdit: setting.EnableDoubleClickEdit,
|
||||
Reactions: setting.Reactions,
|
||||
}
|
||||
}
|
||||
|
||||
func convertInstanceMemoRelatedSettingToStore(setting *v1pb.InstanceSetting_MemoRelatedSetting) *storepb.InstanceMemoRelatedSetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
return &storepb.InstanceMemoRelatedSetting{
|
||||
ContentLengthLimit: setting.ContentLengthLimit,
|
||||
EnableDoubleClickEdit: setting.EnableDoubleClickEdit,
|
||||
Reactions: setting.Reactions,
|
||||
}
|
||||
}
|
||||
|
||||
func convertInstanceTagsSettingFromStore(setting *storepb.InstanceTagsSetting) *v1pb.InstanceSetting_TagsSetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
tags := make(map[string]*v1pb.InstanceSetting_TagMetadata, len(setting.Tags))
|
||||
for tag, metadata := range setting.Tags {
|
||||
tags[tag] = &v1pb.InstanceSetting_TagMetadata{
|
||||
BackgroundColor: metadata.GetBackgroundColor(),
|
||||
BlurContent: metadata.GetBlurContent(),
|
||||
}
|
||||
}
|
||||
return &v1pb.InstanceSetting_TagsSetting{
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
|
||||
func convertInstanceTagsSettingToStore(setting *v1pb.InstanceSetting_TagsSetting) *storepb.InstanceTagsSetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
tags := make(map[string]*storepb.InstanceTagMetadata, len(setting.Tags))
|
||||
for tag, metadata := range setting.Tags {
|
||||
tags[tag] = &storepb.InstanceTagMetadata{
|
||||
BackgroundColor: metadata.GetBackgroundColor(),
|
||||
BlurContent: metadata.GetBlurContent(),
|
||||
}
|
||||
}
|
||||
return &storepb.InstanceTagsSetting{
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
|
||||
func convertInstanceNotificationSettingFromStore(setting *storepb.InstanceNotificationSetting) *v1pb.InstanceSetting_NotificationSetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
notificationSetting := &v1pb.InstanceSetting_NotificationSetting{}
|
||||
if setting.Email != nil {
|
||||
notificationSetting.Email = &v1pb.InstanceSetting_NotificationSetting_EmailSetting{
|
||||
Enabled: setting.Email.Enabled,
|
||||
SmtpHost: setting.Email.SmtpHost,
|
||||
SmtpPort: setting.Email.SmtpPort,
|
||||
SmtpUsername: setting.Email.SmtpUsername,
|
||||
// SmtpPassword is write-only: never returned in responses.
|
||||
FromEmail: setting.Email.FromEmail,
|
||||
FromName: setting.Email.FromName,
|
||||
ReplyTo: setting.Email.ReplyTo,
|
||||
UseTls: setting.Email.UseTls,
|
||||
UseSsl: setting.Email.UseSsl,
|
||||
}
|
||||
}
|
||||
return notificationSetting
|
||||
}
|
||||
|
||||
func convertInstanceNotificationSettingToStore(setting *v1pb.InstanceSetting_NotificationSetting) *storepb.InstanceNotificationSetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
notificationSetting := &storepb.InstanceNotificationSetting{}
|
||||
if setting.Email != nil {
|
||||
notificationSetting.Email = &storepb.InstanceNotificationSetting_EmailSetting{
|
||||
Enabled: setting.Email.Enabled,
|
||||
SmtpHost: setting.Email.SmtpHost,
|
||||
SmtpPort: setting.Email.SmtpPort,
|
||||
SmtpUsername: setting.Email.SmtpUsername,
|
||||
SmtpPassword: setting.Email.SmtpPassword,
|
||||
FromEmail: setting.Email.FromEmail,
|
||||
FromName: setting.Email.FromName,
|
||||
ReplyTo: setting.Email.ReplyTo,
|
||||
UseTls: setting.Email.UseTls,
|
||||
UseSsl: setting.Email.UseSsl,
|
||||
}
|
||||
}
|
||||
return notificationSetting
|
||||
}
|
||||
|
||||
func convertInstanceAISettingFromStore(setting *storepb.InstanceAISetting) *v1pb.InstanceSetting_AISetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
aiSetting := &v1pb.InstanceSetting_AISetting{
|
||||
Providers: make([]*v1pb.InstanceSetting_AIProviderConfig, 0, len(setting.Providers)),
|
||||
Transcription: convertTranscriptionConfigFromStore(setting.GetTranscription()),
|
||||
}
|
||||
for _, provider := range setting.Providers {
|
||||
if provider == nil {
|
||||
continue
|
||||
}
|
||||
apiKey := provider.GetApiKey()
|
||||
aiSetting.Providers = append(aiSetting.Providers, &v1pb.InstanceSetting_AIProviderConfig{
|
||||
Id: provider.GetId(),
|
||||
Title: provider.GetTitle(),
|
||||
Type: v1pb.InstanceSetting_AIProviderType(provider.GetType()),
|
||||
Endpoint: provider.GetEndpoint(),
|
||||
ApiKeySet: apiKey != "",
|
||||
ApiKeyHint: maskAPIKey(apiKey),
|
||||
})
|
||||
}
|
||||
return aiSetting
|
||||
}
|
||||
|
||||
func convertInstanceAISettingToStore(setting *v1pb.InstanceSetting_AISetting) *storepb.InstanceAISetting {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
aiSetting := &storepb.InstanceAISetting{
|
||||
Providers: make([]*storepb.AIProviderConfig, 0, len(setting.Providers)),
|
||||
Transcription: convertTranscriptionConfigToStore(setting.GetTranscription()),
|
||||
}
|
||||
for _, provider := range setting.Providers {
|
||||
if provider == nil {
|
||||
continue
|
||||
}
|
||||
aiSetting.Providers = append(aiSetting.Providers, &storepb.AIProviderConfig{
|
||||
Id: provider.GetId(),
|
||||
Title: provider.GetTitle(),
|
||||
Type: storepb.AIProviderType(provider.GetType()),
|
||||
Endpoint: provider.GetEndpoint(),
|
||||
ApiKey: provider.GetApiKey(),
|
||||
})
|
||||
}
|
||||
return aiSetting
|
||||
}
|
||||
|
||||
func convertTranscriptionConfigFromStore(setting *storepb.TranscriptionConfig) *v1pb.InstanceSetting_TranscriptionConfig {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
return &v1pb.InstanceSetting_TranscriptionConfig{
|
||||
ProviderId: setting.GetProviderId(),
|
||||
Model: setting.GetModel(),
|
||||
Language: setting.GetLanguage(),
|
||||
Prompt: setting.GetPrompt(),
|
||||
}
|
||||
}
|
||||
|
||||
func convertTranscriptionConfigToStore(setting *v1pb.InstanceSetting_TranscriptionConfig) *storepb.TranscriptionConfig {
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
return &storepb.TranscriptionConfig{
|
||||
ProviderId: setting.GetProviderId(),
|
||||
Model: setting.GetModel(),
|
||||
Language: setting.GetLanguage(),
|
||||
Prompt: setting.GetPrompt(),
|
||||
}
|
||||
}
|
||||
|
||||
func validateInstanceSetting(setting *v1pb.InstanceSetting) error {
|
||||
key, err := ExtractInstanceSettingKeyFromName(setting.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key != storepb.InstanceSettingKey_TAGS.String() {
|
||||
return nil
|
||||
}
|
||||
return validateInstanceTagsSetting(setting.GetTagsSetting())
|
||||
}
|
||||
|
||||
func (s *APIV1Service) prepareInstanceAISettingForUpdate(ctx context.Context, setting *storepb.InstanceAISetting) error {
|
||||
if setting == nil {
|
||||
return errors.New("AI setting is required")
|
||||
}
|
||||
|
||||
existing, err := s.Store.GetInstanceAISetting(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get existing AI setting")
|
||||
}
|
||||
existingProviders := map[string]*storepb.AIProviderConfig{}
|
||||
if existing != nil {
|
||||
for _, provider := range existing.Providers {
|
||||
if provider != nil && provider.Id != "" {
|
||||
existingProviders[provider.Id] = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seenIDs := map[string]bool{}
|
||||
for _, provider := range setting.Providers {
|
||||
if provider == nil {
|
||||
return errors.New("provider cannot be nil")
|
||||
}
|
||||
|
||||
provider.Id = strings.TrimSpace(provider.Id)
|
||||
if provider.Id == "" {
|
||||
provider.Id = shortuuid.New()
|
||||
}
|
||||
if seenIDs[provider.Id] {
|
||||
return errors.Errorf("duplicate provider ID %q", provider.Id)
|
||||
}
|
||||
seenIDs[provider.Id] = true
|
||||
|
||||
provider.Title = strings.TrimSpace(provider.Title)
|
||||
if provider.Title == "" {
|
||||
return errors.New("provider title is required")
|
||||
}
|
||||
if provider.Type != storepb.AIProviderType_OPENAI && provider.Type != storepb.AIProviderType_GEMINI {
|
||||
return errors.Errorf("provider %q has unsupported type", provider.Id)
|
||||
}
|
||||
|
||||
provider.Endpoint = strings.TrimSpace(provider.Endpoint)
|
||||
if provider.Type == storepb.AIProviderType_OPENAI && provider.Endpoint == "" {
|
||||
provider.Endpoint = "https://api.openai.com/v1"
|
||||
}
|
||||
if provider.Type == storepb.AIProviderType_GEMINI && provider.Endpoint == "" {
|
||||
provider.Endpoint = "https://generativelanguage.googleapis.com/v1beta"
|
||||
}
|
||||
|
||||
if provider.ApiKey == "" {
|
||||
if existingProvider, ok := existingProviders[provider.Id]; ok {
|
||||
provider.ApiKey = existingProvider.ApiKey
|
||||
}
|
||||
}
|
||||
if provider.ApiKey == "" {
|
||||
return errors.Errorf("provider %q API key is required", provider.Id)
|
||||
}
|
||||
}
|
||||
|
||||
if err := preparePersistedTranscriptionConfig(setting, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preparePersistedTranscriptionConfig(setting *storepb.InstanceAISetting, existing *storepb.InstanceAISetting) error {
|
||||
// Preserve the previously stored transcription config when the request omits it,
|
||||
// matching the same "absence == keep" semantics used for API keys. The preserved
|
||||
// config still falls through to validation below, so a stale provider_id is
|
||||
// rejected if the same update removed or renamed its referenced provider.
|
||||
if setting.Transcription == nil && existing != nil {
|
||||
setting.Transcription = existing.GetTranscription()
|
||||
}
|
||||
if setting.Transcription == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := setting.Transcription
|
||||
cfg.ProviderId = strings.TrimSpace(cfg.ProviderId)
|
||||
cfg.Model = strings.TrimSpace(cfg.Model)
|
||||
cfg.Language = strings.TrimSpace(cfg.Language)
|
||||
cfg.Prompt = strings.TrimSpace(cfg.Prompt)
|
||||
|
||||
if cfg.ProviderId != "" {
|
||||
referenced := false
|
||||
for _, provider := range setting.Providers {
|
||||
if provider != nil && provider.Id == cfg.ProviderId {
|
||||
referenced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !referenced {
|
||||
return errors.Errorf("transcription provider_id %q does not reference any configured provider", cfg.ProviderId)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.Model) > maxTranscriptionConfigModelLength {
|
||||
return errors.Errorf("transcription model is too long; maximum length is %d characters", maxTranscriptionConfigModelLength)
|
||||
}
|
||||
if len(cfg.Language) > maxTranscriptionConfigLanguageLength {
|
||||
return errors.Errorf("transcription language is too long; maximum length is %d characters", maxTranscriptionConfigLanguageLength)
|
||||
}
|
||||
if len(cfg.Prompt) > maxTranscriptionConfigPromptLength {
|
||||
return errors.Errorf("transcription prompt is too long; maximum length is %d characters", maxTranscriptionConfigPromptLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maskAPIKey(apiKey string) string {
|
||||
if apiKey == "" {
|
||||
return ""
|
||||
}
|
||||
if len(apiKey) <= 8 {
|
||||
return "..."
|
||||
}
|
||||
prefixLength := min(4, len(apiKey))
|
||||
return apiKey[:prefixLength] + "..." + apiKey[len(apiKey)-4:]
|
||||
}
|
||||
|
||||
func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) error {
|
||||
if setting == nil {
|
||||
return errors.New("tags setting is required")
|
||||
}
|
||||
for tag, metadata := range setting.Tags {
|
||||
if strings.TrimSpace(tag) == "" {
|
||||
return errors.New("tag key cannot be empty")
|
||||
}
|
||||
if _, err := regexp.Compile(tag); err != nil {
|
||||
return errors.Errorf("tag key %q is not a valid regex pattern: %v", tag, err)
|
||||
}
|
||||
if metadata == nil {
|
||||
return errors.Errorf("tag metadata is required for %q", tag)
|
||||
}
|
||||
if metadata.GetBackgroundColor() != nil {
|
||||
if err := validateInstanceColor(metadata.GetBackgroundColor()); err != nil {
|
||||
return errors.Wrapf(err, "background_color for %q", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInstanceColor(color *colorpb.Color) error {
|
||||
if err := validateInstanceColorComponent("red", color.GetRed()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInstanceColorComponent("green", color.GetGreen()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInstanceColorComponent("blue", color.GetBlue()); err != nil {
|
||||
return err
|
||||
}
|
||||
if alpha := color.GetAlpha(); alpha != nil {
|
||||
if err := validateInstanceColorComponent("alpha", alpha.GetValue()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInstanceColorComponent(name string, value float32) error {
|
||||
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
|
||||
return errors.Errorf("%s must be a finite number", name)
|
||||
}
|
||||
if value < 0 || value > 1 {
|
||||
return errors.Errorf("%s must be between 0 and 1", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user