Add support for import/export of profile icon
This commit is contained in:
108
sync/profile.go
108
sync/profile.go
@@ -12,49 +12,49 @@ import (
|
||||
"github.com/safing/portbase/config"
|
||||
"github.com/safing/portbase/log"
|
||||
"github.com/safing/portmaster/profile"
|
||||
"github.com/vincent-petithory/dataurl"
|
||||
)
|
||||
|
||||
// ProfileExport holds an export of a profile.
|
||||
type ProfileExport struct { //nolint:maligned
|
||||
Type Type `json:"type"`
|
||||
Type Type `json:"type" yaml:"type"`
|
||||
|
||||
// Identification
|
||||
ID string `json:"id,omitempty"`
|
||||
Source profile.ProfileSource `json:"source,omitempty"`
|
||||
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
||||
Source profile.ProfileSource `json:"source,omitempty" yaml:"source,omitempty"`
|
||||
|
||||
// Human Metadata
|
||||
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"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty" yaml:"homepage,omitempty"`
|
||||
PresentationPath string `json:"presPath,omitempty" yaml:"presPath,omitempty"`
|
||||
UsePresentationPath bool `json:"usePresPath,omitempty" yaml:"usePresPath,omitempty"`
|
||||
IconData string `json:"iconData,omitempty" yaml:"iconData,omitempty"` // DataURL
|
||||
|
||||
// Process matching
|
||||
Fingerprints []ProfileFingerprint `json:"fingerprints"`
|
||||
Fingerprints []ProfileFingerprint `json:"fingerprints" yaml:"fingerprints"`
|
||||
|
||||
// Settings
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
Config map[string]any `json:"config,omitempty" yaml:"config,omitempty"`
|
||||
|
||||
// Metadata
|
||||
LastEdited *time.Time `json:"lastEdited,omitempty"`
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Internal bool `json:"internal,omitempty"`
|
||||
LastEdited *time.Time `json:"lastEdited,omitempty" yaml:"lastEdited,omitempty"`
|
||||
Created *time.Time `json:"created,omitempty" yaml:"created,omitempty"`
|
||||
Internal bool `json:"internal,omitempty" yaml:"internal,omitempty"`
|
||||
}
|
||||
|
||||
// ProfileIcon represents a profile icon.
|
||||
// ProfileIcon represents a profile icon only.
|
||||
type ProfileIcon struct {
|
||||
Type profile.IconType `json:"type"`
|
||||
Value string `json:"value"`
|
||||
IconData string `json:"iconData,omitempty" yaml:"iconData,omitempty"` // DataURL
|
||||
}
|
||||
|
||||
// ProfileFingerprint 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"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Key string `json:"key,omitempty" yaml:"key,omitempty"`
|
||||
Operation string `json:"operation" yaml:"operation"`
|
||||
Value string `json:"value" yaml:"value"`
|
||||
MergedFrom string `json:"mergedFrom,omitempty" yaml:"mergedFrom,omitempty"`
|
||||
}
|
||||
|
||||
// ProfileExportRequest is a request for a profile export.
|
||||
@@ -124,7 +124,8 @@ func registerProfileAPI() error {
|
||||
Method: http.MethodPost,
|
||||
Field: "allowUnknown",
|
||||
Description: "Allow importing of unknown values.",
|
||||
}},
|
||||
},
|
||||
},
|
||||
BelongsTo: module,
|
||||
StructFunc: handleImportProfile,
|
||||
}); err != nil {
|
||||
@@ -161,7 +162,7 @@ func handleExportProfile(ar *api.Request) (data []byte, err error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serializeExport(export, ar)
|
||||
return serializeProfileExport(export, ar)
|
||||
}
|
||||
|
||||
func handleImportProfile(ar *api.Request) (any, error) {
|
||||
@@ -230,7 +231,6 @@ func ExportProfile(scopedID string) (*ProfileExport, error) {
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
Homepage: p.Homepage,
|
||||
Icons: convertIconsToExport(p.Icons),
|
||||
PresentationPath: p.PresentationPath,
|
||||
UsePresentationPath: p.UsePresentationPath,
|
||||
|
||||
@@ -253,6 +253,22 @@ func ExportProfile(scopedID string) (*ProfileExport, error) {
|
||||
export.Created = &created
|
||||
}
|
||||
|
||||
// Add first exportable icon to export.
|
||||
if len(p.Icons) > 0 {
|
||||
var err error
|
||||
for _, icon := range p.Icons {
|
||||
var iconDataURL string
|
||||
iconDataURL, err = icon.GetIconAsDataURL()
|
||||
if err == nil {
|
||||
export.IconData = iconDataURL
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to export icon: %w", ErrExportFailed, err)
|
||||
}
|
||||
}
|
||||
|
||||
return export, nil
|
||||
}
|
||||
|
||||
@@ -272,9 +288,8 @@ func ImportProfile(r *ProfileImportRequest, requiredProfileSource profile.Profil
|
||||
profileID := profile.DeriveProfileID(fingerprints)
|
||||
if r.Export.ID != "" && r.Export.ID != profileID {
|
||||
return nil, ErrMismatch
|
||||
} else {
|
||||
r.Export.ID = profileID
|
||||
}
|
||||
r.Export.ID = profileID
|
||||
// Check Fingerprints.
|
||||
_, err := profile.ParseFingerprints(fingerprints, "")
|
||||
if err != nil {
|
||||
@@ -349,7 +364,6 @@ func ImportProfile(r *ProfileImportRequest, requiredProfileSource profile.Profil
|
||||
Name: in.Name,
|
||||
Description: in.Description,
|
||||
Homepage: in.Homepage,
|
||||
Icons: convertIconsToInternal(in.Icons),
|
||||
PresentationPath: in.PresentationPath,
|
||||
UsePresentationPath: in.UsePresentationPath,
|
||||
|
||||
@@ -370,6 +384,22 @@ func ImportProfile(r *ProfileImportRequest, requiredProfileSource profile.Profil
|
||||
p.Created = in.Created.Unix()
|
||||
}
|
||||
|
||||
// Add icon to profile, if set.
|
||||
if in.IconData != "" {
|
||||
du, err := dataurl.DecodeString(in.IconData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: icon data is invalid: %w", ErrImportFailed, err)
|
||||
}
|
||||
filename, err := profile.UpdateProfileIcon(du.Data, du.MediaType.Subtype)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: icon is invalid: %w", ErrImportFailed, err)
|
||||
}
|
||||
p.Icons = []profile.Icon{{
|
||||
Type: profile.IconTypeAPI,
|
||||
Value: filename,
|
||||
}}
|
||||
}
|
||||
|
||||
// Save profile to db.
|
||||
p.SetKey(profile.MakeProfileKey(p.Source, p.ID))
|
||||
err = p.Save()
|
||||
@@ -388,28 +418,6 @@ func ImportProfile(r *ProfileImportRequest, requiredProfileSource profile.Profil
|
||||
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 {
|
||||
|
||||
@@ -15,10 +15,10 @@ import (
|
||||
|
||||
// SingleSettingExport holds an export of a single setting.
|
||||
type SingleSettingExport struct {
|
||||
Type Type `json:"type"` // Must be TypeSingleSetting
|
||||
ID string `json:"id"` // Settings Key
|
||||
Type Type `json:"type" yaml:"type"` // Must be TypeSingleSetting
|
||||
ID string `json:"id" yaml:"id"` // Settings Key
|
||||
|
||||
Value any `json:"value"`
|
||||
Value any `json:"value" yaml:"value"`
|
||||
}
|
||||
|
||||
// SingleSettingImportRequest is a request to import a single setting.
|
||||
|
||||
@@ -15,25 +15,25 @@ import (
|
||||
|
||||
// SettingsExport holds an export of settings.
|
||||
type SettingsExport struct {
|
||||
Type Type `json:"type"`
|
||||
Type Type `json:"type" yaml:"type"`
|
||||
|
||||
Config map[string]any `json:"config"`
|
||||
Config map[string]any `json:"config" yaml:"config"`
|
||||
}
|
||||
|
||||
// SettingsImportRequest is a request to import settings.
|
||||
type SettingsImportRequest struct {
|
||||
ImportRequest `json:",inline"`
|
||||
ImportRequest `json:",inline" yaml:",inline"`
|
||||
|
||||
// Reset all settings of target before import.
|
||||
// The ImportResult also reacts to this flag and correctly reports whether
|
||||
// any settings would be replaced or deleted.
|
||||
Reset bool `json:"reset"`
|
||||
Reset bool `json:"reset" yaml:"reset"`
|
||||
|
||||
// AllowUnknown allows the import of unknown settings.
|
||||
// Otherwise, attempting to import an unknown setting will result in an error.
|
||||
AllowUnknown bool `json:"allowUnknown"`
|
||||
AllowUnknown bool `json:"allowUnknown" yaml:"allowUnknown"`
|
||||
|
||||
Export *SettingsExport `json:"export"`
|
||||
Export *SettingsExport `json:"export" yaml:"export"`
|
||||
}
|
||||
|
||||
func registerSettingsAPI() error {
|
||||
|
||||
83
sync/util.go
83
sync/util.go
@@ -1,12 +1,16 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/safing/jess/filesig"
|
||||
"github.com/safing/portbase/api"
|
||||
"github.com/safing/portbase/container"
|
||||
"github.com/safing/portbase/formats/dsd"
|
||||
)
|
||||
|
||||
@@ -101,28 +105,85 @@ var (
|
||||
)
|
||||
)
|
||||
|
||||
func serializeExport(export any, ar *api.Request) ([]byte, error) {
|
||||
// Serialize data.
|
||||
data, mimeType, format, err := dsd.MimeDump(export, ar.Header.Get("Accept"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize data: %w", err)
|
||||
}
|
||||
ar.ResponseHeader.Set("Content-Type", mimeType)
|
||||
func serializeExport(export any, ar *api.Request) (data []byte, err error) {
|
||||
// Get format.
|
||||
format := dsd.FormatFromAccept(ar.Header.Get("Accept"))
|
||||
|
||||
// Add checksum.
|
||||
// Serialize and add checksum.
|
||||
switch format {
|
||||
case dsd.JSON:
|
||||
data, err = filesig.AddJSONChecksum(data)
|
||||
data, err = json.Marshal(export)
|
||||
if err == nil {
|
||||
data, err = filesig.AddJSONChecksum(data)
|
||||
}
|
||||
case dsd.YAML:
|
||||
data, err = filesig.AddYAMLChecksum(data, filesig.TextPlacementBottom)
|
||||
data, err = yaml.Marshal(export)
|
||||
if err == nil {
|
||||
data, err = filesig.AddYAMLChecksum(data, filesig.TextPlacementBottom)
|
||||
}
|
||||
default:
|
||||
return nil, dsd.ErrIncompatibleFormat
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize: %w", err)
|
||||
}
|
||||
|
||||
// Set Content-Type HTTP Header.
|
||||
ar.ResponseHeader.Set("Content-Type", dsd.FormatToMimeType[format])
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func serializeProfileExport(export *ProfileExport, ar *api.Request) ([]byte, error) {
|
||||
// Do a regular serialize, if we don't need parts.
|
||||
switch {
|
||||
case export.IconData == "":
|
||||
// With no icon, do a regular export.
|
||||
return serializeExport(export, ar)
|
||||
case dsd.FormatFromAccept(ar.Header.Get("Accept")) != dsd.YAML:
|
||||
// Only export in parts for yaml.
|
||||
return serializeExport(export, ar)
|
||||
}
|
||||
|
||||
// Step 1: Separate profile icon.
|
||||
profileIconExport := &ProfileIcon{
|
||||
IconData: export.IconData,
|
||||
}
|
||||
export.IconData = ""
|
||||
|
||||
// Step 2: Serialize main export.
|
||||
profileData, err := yaml.Marshal(export)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize profile data: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Serialize icon only.
|
||||
iconData, err := yaml.Marshal(profileIconExport)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize profile icon: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Stitch data together and add copyright notice for icon.
|
||||
exportData := container.New(
|
||||
profileData,
|
||||
[]byte(`
|
||||
# The application icon below is the property of its respective owner.
|
||||
# The icon is used for identification purposes only, and does not imply any endorsement or affiliation with their respective owners.
|
||||
# It is the sole responsibility of the individual or entity sharing this dataset to ensure they have the necessary permissions to do so.
|
||||
`),
|
||||
iconData,
|
||||
).CompileData()
|
||||
|
||||
// Step 4: Add checksum.
|
||||
exportData, err = filesig.AddYAMLChecksum(exportData, filesig.TextPlacementBottom)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add checksum: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
// Set Content-Type HTTP Header.
|
||||
ar.ResponseHeader.Set("Content-Type", dsd.FormatToMimeType[dsd.YAML])
|
||||
|
||||
return exportData, nil
|
||||
}
|
||||
|
||||
func parseExport(request *ImportRequest, export any) error {
|
||||
|
||||
Reference in New Issue
Block a user