From 602db080c5448a3da36edb67fbf5651264b8071f Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 15 Nov 2023 15:12:00 +0100 Subject: [PATCH] Add export and import for profiles --- profile/api.go | 91 ++++++++ profile/database.go | 6 +- profile/fingerprint.go | 10 +- profile/fingerprint_test.go | 2 +- profile/get.go | 14 +- profile/icon.go | 7 +- profile/icons.go | 69 ++++++ profile/migrations.go | 2 +- profile/module.go | 9 + profile/profile.go | 16 +- sync/module.go | 3 + sync/profile.go | 435 ++++++++++++++++++++++++++++++++++-- sync/settings.go | 89 ++++---- 13 files changed, 668 insertions(+), 85 deletions(-) create mode 100644 profile/icons.go diff --git a/profile/api.go b/profile/api.go index e8213bd6f..d563b5ae0 100644 --- a/profile/api.go +++ b/profile/api.go @@ -1,10 +1,14 @@ package profile import ( + "errors" "fmt" + "net/http" + "strings" "github.com/safing/portbase/api" "github.com/safing/portbase/formats/dsd" + "github.com/safing/portbase/utils" ) func registerAPIEndpoints() error { @@ -19,6 +23,28 @@ func registerAPIEndpoints() error { return err } + if err := api.RegisterEndpoint(api.Endpoint{ + Name: "Get Profile Icon", + Description: "Returns the requested profile icon.", + Path: "profile/icon/{id:[0-9a-f]{40-80}}.{ext:[a-z]{3-4}}", + Read: api.PermitUser, + BelongsTo: module, + DataFunc: handleGetProfileIcon, + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Name: "Update Profile Icon", + Description: "Merge multiple profiles into a new one.", + Path: "profile/icon/update", + Write: api.PermitUser, + BelongsTo: module, + StructFunc: handleUpdateProfileIcon, + }); err != nil { + return err + } + return nil } @@ -64,3 +90,68 @@ func handleMergeProfiles(ar *api.Request) (i interface{}, err error) { New: newProfile.ScopedID(), }, nil } + +func handleGetProfileIcon(ar *api.Request) (data []byte, err error) { + // Get profile icon. + data, err = GetProfileIcon(ar.URLVars["id"], ar.URLVars["ext"]) + if err != nil { + return nil, err + } + + // Set content type for icon. + contentType, ok := utils.MimeTypeByExtension(ar.URLVars["ext"]) + if ok { + ar.ResponseHeader.Set("Content-Type", contentType) + } + + return data, nil +} + +type updateProfileIconResponse struct { + Filename string `json:"filename"` +} + +func handleUpdateProfileIcon(ar *api.Request) (any, error) { + // Check input. + if len(ar.InputData) == 0 { + return nil, api.ErrorWithStatus(errors.New("no content"), http.StatusBadRequest) + } + mimeType := ar.Header.Get("Content-Type") + if mimeType == "" { + return nil, api.ErrorWithStatus(errors.New("no content type"), http.StatusBadRequest) + } + + // Derive image format from content type. + mimeType = strings.TrimSpace(mimeType) + mimeType = strings.ToLower(mimeType) + mimeType, _, _ = strings.Cut(mimeType, ";") + var ext string + switch mimeType { + case "image/gif": + ext = "gif" + case "image/jpeg": + ext = "jpg" + case "image/jpg": + ext = "jpg" + case "image/png": + ext = "png" + case "image/svg+xml": + ext = "svg" + case "image/tiff": + ext = "tiff" + case "image/webp": + ext = "webp" + default: + return "", api.ErrorWithStatus(errors.New("unsupported image format"), http.StatusBadRequest) + } + + // Update profile icon. + filename, err := UpdateProfileIcon(ar.InputData, ext) + if err != nil { + return nil, err + } + + return &updateProfileIconResponse{ + Filename: filename, + }, nil +} diff --git a/profile/database.go b/profile/database.go index d311bd328..a9f927a14 100644 --- a/profile/database.go +++ b/profile/database.go @@ -24,11 +24,13 @@ var profileDB = database.NewInterface(&database.Options{ Internal: true, }) -func makeScopedID(source profileSource, id string) string { +// MakeScopedID returns a scoped profile ID. +func MakeScopedID(source ProfileSource, id string) string { return string(source) + "/" + id } -func makeProfileKey(source profileSource, id string) string { +// MakeProfileKey returns a profile key. +func MakeProfileKey(source ProfileSource, id string) string { return ProfilesDBPath + string(source) + "/" + id } diff --git a/profile/fingerprint.go b/profile/fingerprint.go index 3f62ba9de..9caf87945 100644 --- a/profile/fingerprint.go +++ b/profile/fingerprint.go @@ -67,7 +67,7 @@ type ( // merged from. The merged profile should create a new profile ID derived // from the new fingerprints and add all fingerprints with this field set // to the originating profile ID - MergedFrom string + MergedFrom string // `json:"mergedFrom,omitempty"` } // Tag represents a simple key/value kind of tag used in process metadata @@ -170,7 +170,8 @@ type parsedFingerprints struct { cmdlinePrints []matchingFingerprint } -func parseFingerprints(raw []Fingerprint, deprecatedLinkedPath string) (parsed *parsedFingerprints, firstErr error) { +// ParseFingerprints parses the fingerprints to make them ready for matching. +func ParseFingerprints(raw []Fingerprint, deprecatedLinkedPath string) (parsed *parsedFingerprints, firstErr error) { parsed = &parsedFingerprints{} // Add deprecated LinkedPath to fingerprints, if they are empty. @@ -230,7 +231,7 @@ func parseFingerprints(raw []Fingerprint, deprecatedLinkedPath string) (parsed * default: if firstErr == nil { - firstErr = fmt.Errorf("unknown fingerprint operation: %q", entry.Type) + firstErr = fmt.Errorf("unknown fingerprint operation: %q", entry.Operation) } } } @@ -367,7 +368,8 @@ const ( deriveFPKeyIDForValue ) -func deriveProfileID(fps []Fingerprint) string { +// DeriveProfileID derives a profile ID from the given fingerprints. +func DeriveProfileID(fps []Fingerprint) string { // Sort the fingerprints. sortAndCompactFingerprints(fps) diff --git a/profile/fingerprint_test.go b/profile/fingerprint_test.go index 4857d8bee..3cbdb5512 100644 --- a/profile/fingerprint_test.go +++ b/profile/fingerprint_test.go @@ -47,7 +47,7 @@ func TestDeriveProfileID(t *testing.T) { }) // Check if fingerprint matches. - id := deriveProfileID(fps) + id := DeriveProfileID(fps) assert.Equal(t, "PTSRP7rdCnmvdjRoPMTrtjj7qk7PxR1a9YdBWUGwnZXJh2", id) } } diff --git a/profile/get.go b/profile/get.go index dc37efea4..2a5424cff 100644 --- a/profile/get.go +++ b/profile/get.go @@ -35,7 +35,7 @@ func GetLocalProfile(id string, md MatchingData, createProfileCallback func() *P // Get active profile based on the ID, if available. if id != "" { // Check if there already is an active profile. - profile = getActiveProfile(makeScopedID(SourceLocal, id)) + profile = getActiveProfile(MakeScopedID(SourceLocal, id)) if profile != nil { // Mark active and return if not outdated. if profile.outdated.IsNotSet() { @@ -57,9 +57,9 @@ func GetLocalProfile(id string, md MatchingData, createProfileCallback func() *P return nil, errors.New("cannot get local profiles without ID and matching data") } - profile, err = getProfile(makeScopedID(SourceLocal, id)) + profile, err = getProfile(MakeScopedID(SourceLocal, id)) if err != nil { - return nil, fmt.Errorf("failed to load profile %s by ID: %w", makeScopedID(SourceLocal, id), err) + return nil, fmt.Errorf("failed to load profile %s by ID: %w", MakeScopedID(SourceLocal, id), err) } } @@ -70,7 +70,7 @@ func GetLocalProfile(id string, md MatchingData, createProfileCallback func() *P // Get special profile from DB. if profile == nil { - profile, err = getProfile(makeScopedID(SourceLocal, id)) + profile, err = getProfile(MakeScopedID(SourceLocal, id)) if err != nil && !errors.Is(err, database.ErrNotFound) { log.Warningf("profile: failed to get special profile %s: %s", id, err) } @@ -188,12 +188,12 @@ func getProfile(scopedID string) (profile *Profile, err error) { // findProfile searches for a profile with the given linked path. If it cannot // find one, it will create a new profile for the given linked path. -func findProfile(source profileSource, md MatchingData) (profile *Profile, err error) { +func findProfile(source ProfileSource, md MatchingData) (profile *Profile, err error) { // TODO: Loading every profile from database and parsing it for every new // process might be quite expensive. Measure impact and possibly improve. // Get iterator over all profiles. - it, err := profileDB.Query(query.New(ProfilesDBPath + makeScopedID(source, ""))) + it, err := profileDB.Query(query.New(ProfilesDBPath + MakeScopedID(source, ""))) if err != nil { return nil, fmt.Errorf("failed to query for profiles: %w", err) } @@ -265,7 +265,7 @@ func loadProfileFingerprints(r record.Record) (parsed *parsedFingerprints, err e } // Parse and return fingerprints. - return parseFingerprints(profile.Fingerprints, profile.LinkedPath) + return ParseFingerprints(profile.Fingerprints, profile.LinkedPath) } func loadProfile(r record.Record) (*Profile, error) { diff --git a/profile/icon.go b/profile/icon.go index 0f084be59..91a0ae580 100644 --- a/profile/icon.go +++ b/profile/icon.go @@ -19,14 +19,17 @@ type IconType string const ( IconTypeFile IconType = "path" IconTypeDatabase IconType = "database" + IconTypeAPI IconType = "api" ) func (t IconType) sortOrder() int { switch t { - case IconTypeDatabase: + case IconTypeAPI: return 1 - case IconTypeFile: + case IconTypeDatabase: return 2 + case IconTypeFile: + return 3 default: return 100 } diff --git a/profile/icons.go b/profile/icons.go new file mode 100644 index 000000000..6260d217b --- /dev/null +++ b/profile/icons.go @@ -0,0 +1,69 @@ +package profile + +import ( + "crypto" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/safing/portbase/api" +) + +var profileIconStoragePath = "" + +// GetProfileIcon returns the profile icon with the given ID and extension. +func GetProfileIcon(id, ext string) (data []byte, err error) { + // Build storage path. + iconPath := filepath.Join(profileIconStoragePath, id+"."+ext) + iconPath, err = filepath.Abs(iconPath) + if err != nil { + return nil, fmt.Errorf("failed to check icon path: %w", err) + } + // Do a quick check if we are still within the right directory. + // This check is not entirely correct, but is sufficient for this use case. + if !strings.HasPrefix(iconPath, profileIconStoragePath) { + return nil, api.ErrorWithStatus(errors.New("invalid icon"), http.StatusBadRequest) + } + + return os.ReadFile(iconPath) +} + +// UpdateProfileIcon creates or updates the given icon. +func UpdateProfileIcon(data []byte, ext string) (filename string, err error) { + // Check icon size. + if len(data) > 1_000_000 { + return "", errors.New("icon too big") + } + + // Calculate sha1 sum of icon. + h := crypto.SHA1.New() + if _, err := h.Write(data); err != nil { + return "", err + } + sum := hex.EncodeToString(h.Sum(nil)) + + // Check ext. + ext = strings.ToLower(ext) + switch ext { + case "gif": + case "jpeg": + ext = "jpg" + case "jpg": + case "png": + case "svg": + case "tiff": + case "webp": + default: + return "", errors.New("unsupported icon format") + } + + // Save to disk. + filename = sum + "." + ext + return filename, os.WriteFile(filepath.Join(profileIconStoragePath, filename), data, 0o0644) //nolint:gosec +} + +// TODO: Clean up icons regularly. diff --git a/profile/migrations.go b/profile/migrations.go index f2b33158c..7d1f29792 100644 --- a/profile/migrations.go +++ b/profile/migrations.go @@ -208,7 +208,7 @@ func migrateToDerivedIDs(ctx context.Context, _, to *version.Version, db *databa // Generate new ID. oldScopedID := profile.ScopedID() - newID := deriveProfileID(profile.Fingerprints) + newID := DeriveProfileID(profile.Fingerprints) // If they match, skip migration for this profile. if profile.ID == newID { diff --git a/profile/module.go b/profile/module.go index a462cd7e1..6036d79a4 100644 --- a/profile/module.go +++ b/profile/module.go @@ -2,10 +2,12 @@ package profile import ( "errors" + "fmt" "os" "github.com/safing/portbase/database" "github.com/safing/portbase/database/migration" + "github.com/safing/portbase/dataroot" "github.com/safing/portbase/log" "github.com/safing/portbase/modules" _ "github.com/safing/portmaster/core/base" @@ -45,6 +47,13 @@ func prep() error { return err } + // Setup icon storage location. + iconsDir := dataroot.Root().ChildDir("databases", 0o0700).ChildDir("icons", 0o0700) + if err := iconsDir.Ensure(); err != nil { + return fmt.Errorf("failed to create/check icons directory: %w", err) + } + profileIconStoragePath = iconsDir.Path + return nil } diff --git a/profile/profile.go b/profile/profile.go index db309080d..87228acbd 100644 --- a/profile/profile.go +++ b/profile/profile.go @@ -20,13 +20,13 @@ import ( "github.com/safing/portmaster/profile/endpoints" ) -// profileSource is the source of the profile. -type profileSource string +// ProfileSource is the source of the profile. +type ProfileSource string // Profile Sources. const ( - SourceLocal profileSource = "local" // local, editable - SourceSpecial profileSource = "special" // specials (read-only) + SourceLocal ProfileSource = "local" // local, editable + SourceSpecial ProfileSource = "special" // specials (read-only) ) // Default Action IDs. @@ -45,7 +45,7 @@ type Profile struct { //nolint:maligned // not worth the effort // ID is a unique identifier for the profile. ID string // constant // Source describes the source of the profile. - Source profileSource // constant + Source ProfileSource // constant // Name is a human readable name of the profile. It // defaults to the basename of the application. Name string @@ -262,7 +262,7 @@ func New(profile *Profile) *Profile { if profile.ID == "" { if len(profile.Fingerprints) > 0 { // Derive from fingerprints. - profile.ID = deriveProfileID(profile.Fingerprints) + profile.ID = DeriveProfileID(profile.Fingerprints) } else { // Generate random ID as fallback. log.Warningf("profile: creating new profile without fingerprints to derive ID from") @@ -284,12 +284,12 @@ func New(profile *Profile) *Profile { // ScopedID returns the scoped ID (Source + ID) of the profile. func (profile *Profile) ScopedID() string { - return makeScopedID(profile.Source, profile.ID) + return MakeScopedID(profile.Source, profile.ID) } // makeKey derives and sets the record Key from the profile attributes. func (profile *Profile) makeKey() { - profile.SetKey(makeProfileKey(profile.Source, profile.ID)) + profile.SetKey(MakeProfileKey(profile.Source, profile.ID)) } // Save saves the profile to the database. diff --git a/sync/module.go b/sync/module.go index bdecf4429..0c6ebc633 100644 --- a/sync/module.go +++ b/sync/module.go @@ -25,5 +25,8 @@ func prep() error { if err := registerSingleSettingAPI(); err != nil { return err } + if err := registerProfileAPI(); err != nil { + return err + } return nil } diff --git a/sync/profile.go b/sync/profile.go index 5e7b66c5d..34e8b6253 100644 --- a/sync/profile.go +++ b/sync/profile.go @@ -1,45 +1,440 @@ package sync import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" "time" + "github.com/safing/portbase/api" + "github.com/safing/portbase/config" + "github.com/safing/portbase/log" "github.com/safing/portmaster/profile" ) // ProfileExport holds an export of a profile. type ProfileExport struct { //nolint:maligned - Type Type + Type Type `json:"type"` - // Identification (sync or import as new only) - ID string - Source string + // Identification + ID string `json:"id,omitempty"` + Source profile.ProfileSource `json:"source,omitempty"` // Human Metadata - Name string - Description string - Homepage string - Icons []profile.Icon - PresentationPath string - UsePresentationPath bool + Name string `json:"name"` + Description string `json:"description,omitempty"` + Homepage string `json:"homepage,omitempty"` + Icons []ProfileIcon `json:"icons,omitempty"` + PresentationPath string `json:"presPath,omitempty"` + UsePresentationPath bool `json:"usePresPath,omitempty"` // Process matching - Fingerprints []profile.Fingerprint + Fingerprints []ProfileFingerprint `json:"fingerprints"` // Settings - Config map[string]any + Config map[string]any `json:"config,omitempty"` - // Metadata (sync only) - LastEdited time.Time - Created time.Time - Internal bool + // Metadata + LastEdited *time.Time `json:"lastEdited,omitempty"` + Created *time.Time `json:"created,omitempty"` + Internal bool `json:"internal,omitempty"` +} + +// ProfileIcon represents a profile icon. +type ProfileIcon struct { + Type profile.IconType `json:"type"` + Value string `json:"value"` +} + +// ProfileIcon represents a profile fingerprint. +type ProfileFingerprint struct { + Type string `json:"type"` + Key string `json:"key,omitempty"` + Operation string `json:"operation"` + Value string `json:"value"` + MergedFrom string `json:"mergedFrom,omitempty"` +} + +// ProfileExportRequest is a request for a profile export. +type ProfileExportRequest struct { + ID string `json:"id"` } // ProfileImportRequest is a request to import Profile. type ProfileImportRequest struct { - ImportRequest + ImportRequest `json:",inline"` + + // AllowUnknown allows the import of unknown settings. + // Otherwise, attempting to import an unknown setting will result in an error. + AllowUnknown bool `json:"allowUnknown"` + + // AllowReplace allows the import to replace other existing profiles. + AllowReplace bool `json:"allowReplaceProfiles"` + + Export *ProfileExport `json:"export"` +} + +// ProfileImportResult is returned by successful import operations. +type ProfileImportResult struct { + ImportResult `json:",inline"` + + ReplacesProfiles []string `json:"replacesProfiles"` +} + +func registerProfileAPI() error { + if err := api.RegisterEndpoint(api.Endpoint{ + Name: "Export App Profile", + Description: "Exports app fingerprints, settings and metadata in a share-able format.", + Path: "sync/profile/export", + Read: api.PermitAdmin, + Write: api.PermitAdmin, + Parameters: []api.Parameter{{ + Method: http.MethodGet, + Field: "id", + Description: "Specify scoped profile ID to export.", + }}, + BelongsTo: module, + DataFunc: handleExportProfile, + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Name: "Import App Profile", + Description: "Imports full app profiles, including fingerprints, setting and metadata from the share-able format.", + Path: "sync/profile/import", + Read: api.PermitAdmin, + Write: api.PermitAdmin, + Parameters: []api.Parameter{ + { + Method: http.MethodPost, + Field: "allowReplace", + Description: "Allow replacing existing profiles.", + }, { + Method: http.MethodPost, + Field: "validate", + Description: "Validate only.", + }, { + Method: http.MethodPost, + Field: "reset", + Description: "Replace all existing settings.", + }, { + Method: http.MethodPost, + Field: "allowUnknown", + Description: "Allow importing of unknown values.", + }}, + BelongsTo: module, + StructFunc: handleImportProfile, + }); err != nil { + return err + } + + return nil +} + +func handleExportProfile(ar *api.Request) (data []byte, err error) { + var request *ProfileExportRequest + + // Get parameters. + q := ar.URL.Query() + if len(q) > 0 { + request = &ProfileExportRequest{ + ID: q.Get("id"), + } + } else { + request = &ProfileExportRequest{} + if err := json.Unmarshal(ar.InputData, request); err != nil { + return nil, fmt.Errorf("%w: failed to parse export request: %w", ErrExportFailed, err) + } + } + + // Check parameters. + if request.ID == "" { + return nil, errors.New("missing parameters") + } + + // Export. + export, err := ExportProfile(request.ID) + if err != nil { + return nil, err + } + + return serializeExport(export, ar) +} + +func handleImportProfile(ar *api.Request) (any, error) { + var request *ProfileImportRequest + + // Get parameters. + q := ar.URL.Query() + if len(q) > 0 { + request = &ProfileImportRequest{ + ImportRequest: ImportRequest{ + ValidateOnly: q.Has("validate"), + RawExport: string(ar.InputData), + RawMime: ar.Header.Get("Content-Type"), + }, + AllowUnknown: q.Has("allowUnknown"), + AllowReplace: q.Has("allowReplace"), + } + } else { + request = &ProfileImportRequest{} + if err := json.Unmarshal(ar.InputData, request); err != nil { + return nil, fmt.Errorf("%w: failed to parse import request: %w", ErrInvalidImportRequest, err) + } + } + + // Check if we need to parse the export. + switch { + case request.Export != nil && request.RawExport != "": + return nil, fmt.Errorf("%w: both Export and RawExport are defined", ErrInvalidImportRequest) + case request.RawExport != "": + // Parse export. + export := &ProfileExport{} + if err := parseExport(&request.ImportRequest, export); err != nil { + return nil, err + } + request.Export = export + case request.Export != nil: + // Export is aleady parsed. + default: + return nil, ErrInvalidImportRequest + } + + // Import. + return ImportProfile(request, profile.SourceLocal) +} + +// ExportProfile exports a profile. +func ExportProfile(scopedID string) (*ProfileExport, error) { + // Get Profile. + r, err := db.Get(profile.ProfilesDBPath + scopedID) + if err != nil { + return nil, fmt.Errorf("%w: failed to find profile: %w", ErrTargetNotFound, err) + } + p, err := profile.EnsureProfile(r) + if err != nil { + return nil, fmt.Errorf("%w: failed to load profile: %w", ErrExportFailed, err) + } + + // Copy exportable profile data. + export := &ProfileExport{ + Type: TypeProfile, - // Reset all settings and fingerprints of target before import. - Reset bool + // Identification + ID: p.ID, + Source: p.Source, + + // Human Metadata + Name: p.Name, + Description: p.Description, + Homepage: p.Homepage, + Icons: convertIconsToExport(p.Icons), + PresentationPath: p.PresentationPath, + UsePresentationPath: p.UsePresentationPath, + + // Process matching + Fingerprints: convertFingerprintsToExport(p.Fingerprints), + + // Settings + Config: p.Config, + + // Metadata + Internal: p.Internal, + } + // Add optional timestamps. + if p.LastEdited > 0 { + lastEdited := time.Unix(p.LastEdited, 0) + export.LastEdited = &lastEdited + } + if p.Created > 0 { + created := time.Unix(p.Created, 0) + export.Created = &created + } + + return export, nil +} + +// ImportProfile imports a profile. +func ImportProfile(r *ProfileImportRequest, requiredProfileSource profile.ProfileSource) (*ProfileImportResult, error) { + // Check import. + if r.Export.Type != TypeProfile { + return nil, ErrMismatch + } + + // Check Source. + if r.Export.Source != "" && r.Export.Source != requiredProfileSource { + return nil, ErrMismatch + } + // Check ID. + fingerprints := convertFingerprintsToInternal(r.Export.Fingerprints) + profileID := profile.DeriveProfileID(fingerprints) + if r.Export.ID != "" && r.Export.ID != profileID { + return nil, ErrMismatch + } else { + r.Export.ID = profileID + } + // Check Fingerprints. + _, err := profile.ParseFingerprints(fingerprints, "") + if err != nil { + return nil, fmt.Errorf("%w: the export contains invalid fingerprints: %w", ErrInvalidProfileData, err) + } + + // Flatten config. + settings := config.Flatten(r.Export.Config) + + // Check settings. + settingsResult, globalOnlySettingFound, err := checkSettings(settings) + if err != nil { + return nil, err + } + if settingsResult.ContainsUnknown && !r.AllowUnknown && !r.ValidateOnly { + return nil, fmt.Errorf("%w: the export contains unknown settings", ErrInvalidImportRequest) + } + // Check if a setting is settable per app. + if globalOnlySettingFound { + return nil, fmt.Errorf("%w: export contains settings that cannot be set per app", ErrNotSettablePerApp) + } + + // Create result based on settings result. + result := &ProfileImportResult{ + ImportResult: *settingsResult, + } + + // Check if the profile already exists. + exists, err := db.Exists(profile.MakeProfileKey(r.Export.Source, r.Export.ID)) + if err != nil { + return nil, fmt.Errorf("internal import error: %w", err) + } + if exists { + result.ReplacesExisting = true + } + + // Check if import will delete any profiles. + requiredSourcePrefix := string(r.Export.Source) + "/" + result.ReplacesProfiles = make([]string, 0, len(r.Export.Fingerprints)) + for _, fp := range r.Export.Fingerprints { + if fp.MergedFrom != "" { + if !strings.HasPrefix(fp.MergedFrom, requiredSourcePrefix) { + return nil, fmt.Errorf("%w: exported profile was merged from different profile source", ErrInvalidImportRequest) + } + exists, err := db.Exists(profile.ProfilesDBPath + fp.MergedFrom) + if err != nil { + return nil, fmt.Errorf("internal import error: %w", err) + } + if exists { + result.ReplacesProfiles = append(result.ReplacesProfiles, fp.MergedFrom) + } + } + } + + // Stop here if we are only validating. + if r.ValidateOnly { + return result, nil + } + if result.ReplacesExisting && !r.AllowReplace { + return nil, fmt.Errorf("%w: import would replace existing profile", ErrImportFailed) + } + + // Create profile from export. + // Note: Don't use profile.New(), as this will not trigger a profile refresh if active. + in := r.Export + p := &profile.Profile{ + // Identification + ID: in.ID, + Source: requiredProfileSource, + + // Human Metadata + Name: in.Name, + Description: in.Description, + Homepage: in.Homepage, + Icons: convertIconsToInternal(in.Icons), + PresentationPath: in.PresentationPath, + UsePresentationPath: in.UsePresentationPath, + + // Process matching + Fingerprints: fingerprints, + + // Settings + Config: in.Config, + + // Metadata + Internal: in.Internal, + } + // Add optional timestamps. + if in.LastEdited != nil { + p.LastEdited = in.LastEdited.Unix() + } + if in.Created != nil { + p.Created = in.Created.Unix() + } + + // Save profile to db. + p.SetKey(profile.MakeProfileKey(p.Source, p.ID)) + err = p.Save() + if err != nil { + return nil, fmt.Errorf("%w: failed to save profile: %w", ErrImportFailed, err) + } + + // Delete profiles that were merged into the imported profile. + for _, profileID := range result.ReplacesProfiles { + err := db.Delete(profile.ProfilesDBPath + profileID) + if err != nil { + log.Errorf("sync: failed to delete merged profile %s on import: %s", profileID, err) + } + } + + return result, nil +} + +func convertIconsToExport(icons []profile.Icon) []ProfileIcon { + converted := make([]ProfileIcon, 0, len(icons)) + for _, icon := range icons { + converted = append(converted, ProfileIcon{ + Type: icon.Type, + Value: icon.Value, + }) + } + return converted +} + +func convertIconsToInternal(icons []ProfileIcon) []profile.Icon { + converted := make([]profile.Icon, 0, len(icons)) + for _, icon := range icons { + converted = append(converted, profile.Icon{ + Type: icon.Type, + Value: icon.Value, + }) + } + return converted +} + +func convertFingerprintsToExport(fingerprints []profile.Fingerprint) []ProfileFingerprint { + converted := make([]ProfileFingerprint, 0, len(fingerprints)) + for _, fp := range fingerprints { + converted = append(converted, ProfileFingerprint{ + Type: fp.Type, + Key: fp.Key, + Operation: fp.Operation, + Value: fp.Value, + MergedFrom: fp.MergedFrom, + }) + } + return converted +} - Export *ProfileExport +func convertFingerprintsToInternal(fingerprints []ProfileFingerprint) []profile.Fingerprint { + converted := make([]profile.Fingerprint, 0, len(fingerprints)) + for _, fp := range fingerprints { + converted = append(converted, profile.Fingerprint{ + Type: fp.Type, + Key: fp.Key, + Operation: fp.Operation, + Value: fp.Value, + MergedFrom: fp.MergedFrom, + }) + } + return converted } diff --git a/sync/settings.go b/sync/settings.go index c9ada9a81..ae033fa5d 100644 --- a/sync/settings.go +++ b/sync/settings.go @@ -221,52 +221,16 @@ func ImportSettings(r *SettingsImportRequest) (*ImportResult, error) { if r.Export.Type != TypeSettings { return nil, ErrMismatch } - // Flatten config. settings := config.Flatten(r.Export.Config) - // Validate config and gather some metadata. - var ( - result = &ImportResult{} - checked int - globalOnlySettingFound bool - ) - err := config.ForEachOption(func(option *config.Option) error { - // Check if any setting is set. - if r.Reset && option.IsSetByUser() { - result.ReplacesExisting = true - } - - newValue, ok := settings[option.Key] - if ok { - checked++ - - // Validate the new value. - if err := option.ValidateValue(newValue); err != nil { - return fmt.Errorf("%w: configuration value for %s is invalid: %w", ErrInvalidSettingValue, option.Key, err) - } - - // Collect metadata. - if option.RequiresRestart { - result.RestartRequired = true - } - if !r.Reset && option.IsSetByUser() { - result.ReplacesExisting = true - } - if !option.AnnotationEquals(config.SettablePerAppAnnotation, true) { - globalOnlySettingFound = true - } - } - return nil - }) + // Check settings. + result, globalOnlySettingFound, err := checkSettings(settings) if err != nil { return nil, err } - if checked < len(settings) { - result.ContainsUnknown = true - if !r.AllowUnknown && !r.ValidateOnly { - return nil, fmt.Errorf("%w: the export contains unknown settings", ErrInvalidImportRequest) - } + if result.ContainsUnknown && !r.AllowUnknown && !r.ValidateOnly { + return nil, fmt.Errorf("%w: the export contains unknown settings", ErrInvalidImportRequest) } // Import global settings. @@ -334,3 +298,48 @@ func ImportSettings(r *SettingsImportRequest) (*ImportResult, error) { return result, nil } + +func checkSettings(settings map[string]any) (result *ImportResult, globalOnlySettingFound bool, err error) { + result = &ImportResult{} + + // Validate config and gather some metadata. + var checked int + err = config.ForEachOption(func(option *config.Option) error { + // Check if any setting is set. + // TODO: Fix this - it only checks for global settings. + // if r.Reset && option.IsSetByUser() { + // result.ReplacesExisting = true + // } + + newValue, ok := settings[option.Key] + if ok { + checked++ + + // Validate the new value. + if err := option.ValidateValue(newValue); err != nil { + return fmt.Errorf("%w: configuration value for %s is invalid: %w", ErrInvalidSettingValue, option.Key, err) + } + + // Collect metadata. + if option.RequiresRestart { + result.RestartRequired = true + } + // TODO: Fix this - it only checks for global settings. + // if !r.Reset && option.IsSetByUser() { + // result.ReplacesExisting = true + // } + if !option.AnnotationEquals(config.SettablePerAppAnnotation, true) { + globalOnlySettingFound = true + } + } + return nil + }) + if err != nil { + return nil, false, err + } + if checked < len(settings) { + result.ContainsUnknown = true + } + + return result, globalOnlySettingFound, nil +}