wip: migrate to mono-repo. SPN has already been moved to spn/
This commit is contained in:
32
service/sync/module.go
Normal file
32
service/sync/module.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/safing/portbase/database"
|
||||
"github.com/safing/portbase/modules"
|
||||
)
|
||||
|
||||
var (
|
||||
module *modules.Module
|
||||
|
||||
db = database.NewInterface(&database.Options{
|
||||
Local: true,
|
||||
Internal: true,
|
||||
})
|
||||
)
|
||||
|
||||
func init() {
|
||||
module = modules.Register("sync", prep, nil, nil, "profiles")
|
||||
}
|
||||
|
||||
func prep() error {
|
||||
if err := registerSettingsAPI(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerSingleSettingAPI(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerProfileAPI(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
473
service/sync/profile.go
Normal file
473
service/sync/profile.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/vincent-petithory/dataurl"
|
||||
|
||||
"github.com/safing/portbase/api"
|
||||
"github.com/safing/portbase/config"
|
||||
"github.com/safing/portbase/log"
|
||||
"github.com/safing/portmaster/service/profile"
|
||||
"github.com/safing/portmaster/service/profile/binmeta"
|
||||
)
|
||||
|
||||
// ProfileExport holds an export of a profile.
|
||||
type ProfileExport struct { //nolint:maligned
|
||||
Type Type `json:"type" yaml:"type"`
|
||||
|
||||
// Identification
|
||||
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
||||
Source profile.ProfileSource `json:"source,omitempty" yaml:"source,omitempty"`
|
||||
|
||||
// Human Metadata
|
||||
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" yaml:"fingerprints"`
|
||||
|
||||
// Settings
|
||||
Config map[string]any `json:"config,omitempty" yaml:"config,omitempty"`
|
||||
|
||||
// Metadata
|
||||
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 only.
|
||||
type ProfileIcon struct {
|
||||
IconData string `json:"iconData,omitempty" yaml:"iconData,omitempty"` // DataURL
|
||||
}
|
||||
|
||||
// ProfileFingerprint represents a profile fingerprint.
|
||||
type ProfileFingerprint struct {
|
||||
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.
|
||||
type ProfileExportRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// ProfileImportRequest is a request to import Profile.
|
||||
type ProfileImportRequest struct {
|
||||
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 serializeProfileExport(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 {
|
||||
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,
|
||||
|
||||
// Identification
|
||||
ID: p.ID,
|
||||
Source: p.Source,
|
||||
|
||||
// Human Metadata
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
Homepage: p.Homepage,
|
||||
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
|
||||
}
|
||||
|
||||
// Derive ID to ensure the ID is always correct.
|
||||
export.ID = profile.DeriveProfileID(p.Fingerprints)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove presentation path if both Name and Icon are set.
|
||||
if export.Name != "" && export.IconData != "" {
|
||||
p.UsePresentationPath = false
|
||||
}
|
||||
if !p.UsePresentationPath {
|
||||
p.PresentationPath = ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// Convert fingerprints to internal representation.
|
||||
fingerprints := convertFingerprintsToInternal(r.Export.Fingerprints)
|
||||
if len(fingerprints) == 0 {
|
||||
return nil, fmt.Errorf("%w: the export contains no fingerprints", ErrInvalidProfileData)
|
||||
}
|
||||
// Derive ID from fingerprints.
|
||||
profileID := profile.DeriveProfileID(fingerprints)
|
||||
if r.Export.ID != "" && r.Export.ID != profileID {
|
||||
return nil, fmt.Errorf("%w: the export profile ID does not match the fingerprints, remove to ignore", ErrInvalidProfileData)
|
||||
}
|
||||
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,
|
||||
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()
|
||||
}
|
||||
|
||||
// Fill in required values.
|
||||
if p.Config == nil {
|
||||
p.Config = make(map[string]any)
|
||||
}
|
||||
if p.Created == 0 {
|
||||
p.Created = time.Now().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 := binmeta.UpdateProfileIcon(du.Data, du.MediaType.Subtype)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: icon is invalid: %w", ErrImportFailed, err)
|
||||
}
|
||||
p.Icons = []binmeta.Icon{{
|
||||
Type: binmeta.IconTypeAPI,
|
||||
Value: filename,
|
||||
Source: binmeta.IconSourceImport,
|
||||
}}
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
270
service/sync/setting_single.go
Normal file
270
service/sync/setting_single.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/safing/portbase/api"
|
||||
"github.com/safing/portbase/config"
|
||||
"github.com/safing/portbase/formats/dsd"
|
||||
"github.com/safing/portmaster/service/profile"
|
||||
)
|
||||
|
||||
// SingleSettingExport holds an export of a single setting.
|
||||
type SingleSettingExport struct {
|
||||
Type Type `json:"type" yaml:"type"` // Must be TypeSingleSetting
|
||||
ID string `json:"id" yaml:"id"` // Settings Key
|
||||
|
||||
Value any `json:"value" yaml:"value"`
|
||||
}
|
||||
|
||||
// SingleSettingImportRequest is a request to import a single setting.
|
||||
type SingleSettingImportRequest struct {
|
||||
ImportRequest `json:",inline"`
|
||||
|
||||
Export *SingleSettingExport `json:"export"`
|
||||
}
|
||||
|
||||
func registerSingleSettingAPI() error {
|
||||
if err := api.RegisterEndpoint(api.Endpoint{
|
||||
Name: "Export Single Setting",
|
||||
Description: "Exports a single setting in a share-able format.",
|
||||
Path: "sync/single-setting/export",
|
||||
Read: api.PermitAdmin,
|
||||
Write: api.PermitAdmin,
|
||||
Parameters: []api.Parameter{{
|
||||
Method: http.MethodGet,
|
||||
Field: "from",
|
||||
Description: "Specify where to export from.",
|
||||
}, {
|
||||
Method: http.MethodGet,
|
||||
Field: "key",
|
||||
Description: "Specify which settings key to export.",
|
||||
}},
|
||||
BelongsTo: module,
|
||||
DataFunc: handleExportSingleSetting,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := api.RegisterEndpoint(api.Endpoint{
|
||||
Name: "Import Single Setting",
|
||||
Description: "Imports a single setting from the share-able format.",
|
||||
Path: "sync/single-setting/import",
|
||||
Read: api.PermitAdmin,
|
||||
Write: api.PermitAdmin,
|
||||
Parameters: []api.Parameter{{
|
||||
Method: http.MethodPost,
|
||||
Field: "to",
|
||||
Description: "Specify where to import to.",
|
||||
}, {
|
||||
Method: http.MethodPost,
|
||||
Field: "key",
|
||||
Description: "Specify which setting key to import.",
|
||||
}, {
|
||||
Method: http.MethodPost,
|
||||
Field: "validate",
|
||||
Description: "Validate only.",
|
||||
}},
|
||||
BelongsTo: module,
|
||||
StructFunc: handleImportSingleSetting,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleExportSingleSetting(ar *api.Request) (data []byte, err error) {
|
||||
var request *ExportRequest
|
||||
|
||||
// Get parameters.
|
||||
q := ar.URL.Query()
|
||||
if len(q) > 0 {
|
||||
request = &ExportRequest{
|
||||
From: q.Get("from"),
|
||||
Keys: q["key"], // Get []string by direct map access.
|
||||
}
|
||||
} else {
|
||||
request = &ExportRequest{}
|
||||
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.From == "" || len(request.Keys) != 1 {
|
||||
return nil, errors.New("missing or malformed parameters")
|
||||
}
|
||||
|
||||
// Export.
|
||||
export, err := ExportSingleSetting(request.Keys[0], request.From)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serializeExport(export, ar)
|
||||
}
|
||||
|
||||
func handleImportSingleSetting(ar *api.Request) (any, error) {
|
||||
var request *SingleSettingImportRequest
|
||||
|
||||
// Get parameters.
|
||||
q := ar.URL.Query()
|
||||
if len(q) > 0 {
|
||||
request = &SingleSettingImportRequest{
|
||||
ImportRequest: ImportRequest{
|
||||
Target: q.Get("to"),
|
||||
ValidateOnly: q.Has("validate"),
|
||||
RawExport: string(ar.InputData),
|
||||
RawMime: ar.Header.Get("Content-Type"),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
request = &SingleSettingImportRequest{}
|
||||
if _, err := dsd.MimeLoad(ar.InputData, ar.Header.Get("Accept"), 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 := &SingleSettingExport{}
|
||||
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
|
||||
}
|
||||
|
||||
// Optional check if the setting key matches.
|
||||
if len(q) > 0 && q.Has("key") && q.Get("key") != request.Export.ID {
|
||||
return nil, ErrMismatch
|
||||
}
|
||||
|
||||
// Import.
|
||||
return ImportSingeSetting(request)
|
||||
}
|
||||
|
||||
// ExportSingleSetting export a single setting.
|
||||
func ExportSingleSetting(key, from string) (*SingleSettingExport, error) {
|
||||
option, err := config.GetOption(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: configuration %w", ErrSettingNotFound, err)
|
||||
}
|
||||
|
||||
var value any
|
||||
if from == ExportTargetGlobal {
|
||||
value = option.UserValue()
|
||||
if value == nil {
|
||||
return nil, ErrUnchanged
|
||||
}
|
||||
} else {
|
||||
// Check if the setting is settable per app.
|
||||
if !option.AnnotationEquals(config.SettablePerAppAnnotation, true) {
|
||||
return nil, ErrNotSettablePerApp
|
||||
}
|
||||
// Get and load profile.
|
||||
r, err := db.Get(profile.ProfilesDBPath + from)
|
||||
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)
|
||||
}
|
||||
// Flatten config and get key we are looking for.
|
||||
flattened := config.Flatten(p.Config)
|
||||
value = flattened[key]
|
||||
if value == nil {
|
||||
return nil, ErrUnchanged
|
||||
}
|
||||
}
|
||||
|
||||
return &SingleSettingExport{
|
||||
Type: TypeSingleSetting,
|
||||
ID: key,
|
||||
Value: value,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ImportSingeSetting imports a single setting.
|
||||
func ImportSingeSetting(r *SingleSettingImportRequest) (*ImportResult, error) {
|
||||
// Check import.
|
||||
if r.Export.Type != TypeSingleSetting {
|
||||
return nil, ErrMismatch
|
||||
}
|
||||
|
||||
// Get option and validate value.
|
||||
option, err := config.GetOption(r.Export.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: configuration %w", ErrSettingNotFound, err)
|
||||
}
|
||||
if err := option.ValidateValue(r.Export.Value); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidSettingValue, err)
|
||||
}
|
||||
|
||||
// Import single global setting.
|
||||
if r.Target == ExportTargetGlobal {
|
||||
// Stop here if we are only validating.
|
||||
if r.ValidateOnly {
|
||||
return &ImportResult{
|
||||
RestartRequired: option.RequiresRestart,
|
||||
ReplacesExisting: option.IsSetByUser(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Actually import the setting.
|
||||
if err := config.SetConfigOption(r.Export.ID, r.Export.Value); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidSettingValue, err)
|
||||
}
|
||||
} else {
|
||||
// Check if the setting is settable per app.
|
||||
if !option.AnnotationEquals(config.SettablePerAppAnnotation, true) {
|
||||
return nil, ErrNotSettablePerApp
|
||||
}
|
||||
// Import single setting into profile.
|
||||
rec, err := db.Get(profile.ProfilesDBPath + r.Target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to find profile: %w", ErrTargetNotFound, err)
|
||||
}
|
||||
p, err := profile.EnsureProfile(rec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to load profile: %w", ErrImportFailed, err)
|
||||
}
|
||||
|
||||
// Stop here if we are only validating.
|
||||
if r.ValidateOnly {
|
||||
return &ImportResult{
|
||||
RestartRequired: option.RequiresRestart,
|
||||
ReplacesExisting: option.IsSetByUser(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Set imported setting on profile.
|
||||
config.PutValueIntoHierarchicalConfig(p.Config, r.Export.ID, r.Export.Value)
|
||||
|
||||
// Mark profile as edited by user.
|
||||
p.LastEdited = time.Now().Unix()
|
||||
|
||||
// Save profile back to db.
|
||||
if err := p.Save(); err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to save profile: %w", ErrImportFailed, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &ImportResult{
|
||||
RestartRequired: option.RequiresRestart,
|
||||
ReplacesExisting: option.IsSetByUser(),
|
||||
}, nil
|
||||
}
|
||||
351
service/sync/settings.go
Normal file
351
service/sync/settings.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/safing/portbase/api"
|
||||
"github.com/safing/portbase/config"
|
||||
"github.com/safing/portmaster/service/profile"
|
||||
)
|
||||
|
||||
// SettingsExport holds an export of settings.
|
||||
type SettingsExport struct {
|
||||
Type Type `json:"type" yaml:"type"`
|
||||
|
||||
Config map[string]any `json:"config" yaml:"config"`
|
||||
}
|
||||
|
||||
// SettingsImportRequest is a request to import settings.
|
||||
type SettingsImportRequest struct {
|
||||
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" 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" yaml:"allowUnknown"`
|
||||
|
||||
Export *SettingsExport `json:"export" yaml:"export"`
|
||||
}
|
||||
|
||||
func registerSettingsAPI() error {
|
||||
if err := api.RegisterEndpoint(api.Endpoint{
|
||||
Name: "Export Settings",
|
||||
Description: "Exports settings in a share-able format.",
|
||||
Path: "sync/settings/export",
|
||||
Read: api.PermitAdmin,
|
||||
Write: api.PermitAdmin,
|
||||
Parameters: []api.Parameter{{
|
||||
Method: http.MethodGet,
|
||||
Field: "from",
|
||||
Description: "Specify where to export from.",
|
||||
}, {
|
||||
Method: http.MethodGet,
|
||||
Field: "key",
|
||||
Description: "Optionally select a single setting to export. Repeat to export selection.",
|
||||
}},
|
||||
BelongsTo: module,
|
||||
DataFunc: handleExportSettings,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := api.RegisterEndpoint(api.Endpoint{
|
||||
Name: "Import Settings",
|
||||
Description: "Imports settings from the share-able format.",
|
||||
Path: "sync/settings/import",
|
||||
Read: api.PermitAdmin,
|
||||
Write: api.PermitAdmin,
|
||||
Parameters: []api.Parameter{{
|
||||
Method: http.MethodPost,
|
||||
Field: "to",
|
||||
Description: "Specify where to import to.",
|
||||
}, {
|
||||
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: handleImportSettings,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleExportSettings(ar *api.Request) (data []byte, err error) {
|
||||
var request *ExportRequest
|
||||
|
||||
// Get parameters.
|
||||
q := ar.URL.Query()
|
||||
if len(q) > 0 {
|
||||
request = &ExportRequest{
|
||||
From: q.Get("from"),
|
||||
Keys: q["key"], // Get []string by direct map access.
|
||||
}
|
||||
} else {
|
||||
request = &ExportRequest{}
|
||||
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.From == "" {
|
||||
return nil, errors.New("missing parameters")
|
||||
}
|
||||
|
||||
// Export.
|
||||
export, err := ExportSettings(request.From, request.Keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serializeExport(export, ar)
|
||||
}
|
||||
|
||||
func handleImportSettings(ar *api.Request) (any, error) {
|
||||
var request *SettingsImportRequest
|
||||
|
||||
// Get parameters.
|
||||
q := ar.URL.Query()
|
||||
if len(q) > 0 {
|
||||
request = &SettingsImportRequest{
|
||||
ImportRequest: ImportRequest{
|
||||
Target: q.Get("to"),
|
||||
ValidateOnly: q.Has("validate"),
|
||||
RawExport: string(ar.InputData),
|
||||
RawMime: ar.Header.Get("Content-Type"),
|
||||
},
|
||||
Reset: q.Has("reset"),
|
||||
AllowUnknown: q.Has("allowUnknown"),
|
||||
}
|
||||
} else {
|
||||
request = &SettingsImportRequest{}
|
||||
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 := &SettingsExport{}
|
||||
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 ImportSettings(request)
|
||||
}
|
||||
|
||||
// ExportSettings exports the global settings.
|
||||
func ExportSettings(from string, keys []string) (*SettingsExport, error) {
|
||||
var settings map[string]any
|
||||
if from == ExportTargetGlobal {
|
||||
// Collect all changed global settings.
|
||||
settings = make(map[string]any)
|
||||
_ = config.ForEachOption(func(option *config.Option) error {
|
||||
v := option.UserValue()
|
||||
if v != nil {
|
||||
settings[option.Key] = v
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
r, err := db.Get(profile.ProfilesDBPath + from)
|
||||
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)
|
||||
}
|
||||
settings = config.Flatten(p.Config)
|
||||
}
|
||||
|
||||
// Only extract some setting keys, if wanted.
|
||||
if len(keys) > 0 {
|
||||
selection := make(map[string]any, len(keys))
|
||||
for _, key := range keys {
|
||||
if v, ok := settings[key]; ok {
|
||||
selection[key] = v
|
||||
}
|
||||
}
|
||||
settings = selection
|
||||
}
|
||||
|
||||
// Check if there any changed settings.
|
||||
if len(settings) == 0 {
|
||||
return nil, ErrUnchanged
|
||||
}
|
||||
|
||||
// Expand config to hierarchical form.
|
||||
settings = config.Expand(settings)
|
||||
|
||||
return &SettingsExport{
|
||||
Type: TypeSettings,
|
||||
Config: settings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ImportSettings imports the global settings.
|
||||
func ImportSettings(r *SettingsImportRequest) (*ImportResult, error) {
|
||||
// Check import.
|
||||
if r.Export.Type != TypeSettings {
|
||||
return nil, ErrMismatch
|
||||
}
|
||||
// Flatten config.
|
||||
settings := config.Flatten(r.Export.Config)
|
||||
|
||||
// Check settings.
|
||||
result, globalOnlySettingFound, err := checkSettings(settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.ContainsUnknown && !r.AllowUnknown && !r.ValidateOnly {
|
||||
return nil, fmt.Errorf("%w: the export contains unknown settings", ErrInvalidImportRequest)
|
||||
}
|
||||
|
||||
// Import global settings.
|
||||
if r.Target == ExportTargetGlobal {
|
||||
// Stop here if we are only validating.
|
||||
if r.ValidateOnly {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Import to global config.
|
||||
vErrs, restartRequired := config.ReplaceConfig(settings)
|
||||
if len(vErrs) > 0 {
|
||||
s := make([]string, 0, len(vErrs))
|
||||
for _, err := range vErrs {
|
||||
s = append(s, err.Error())
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"%w: the supplied configuration could not be applied:\n%s",
|
||||
ErrImportFailed,
|
||||
strings.Join(s, "\n"),
|
||||
)
|
||||
}
|
||||
|
||||
// Save new config to disk.
|
||||
err := config.SaveConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
result.RestartRequired = restartRequired
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Get and load profile.
|
||||
rec, err := db.Get(profile.ProfilesDBPath + r.Target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to find profile: %w", ErrTargetNotFound, err)
|
||||
}
|
||||
p, err := profile.EnsureProfile(rec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to load profile: %w", ErrImportFailed, err)
|
||||
}
|
||||
|
||||
// Stop here if we are only validating.
|
||||
if r.ValidateOnly {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Import settings into profile.
|
||||
if r.Reset {
|
||||
p.Config = config.Expand(settings)
|
||||
} else {
|
||||
for k, v := range settings {
|
||||
config.PutValueIntoHierarchicalConfig(p.Config, k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark profile as edited by user.
|
||||
p.LastEdited = time.Now().Unix()
|
||||
|
||||
// Save profile back to db.
|
||||
err = p.Save()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to save profile: %w", ErrImportFailed, err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
209
service/sync/util.go
Normal file
209
service/sync/util.go
Normal file
@@ -0,0 +1,209 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Type is the type of an export.
|
||||
type Type string
|
||||
|
||||
// Export Types.
|
||||
const (
|
||||
TypeProfile = "profile"
|
||||
TypeSettings = "settings"
|
||||
TypeSingleSetting = "single-setting"
|
||||
)
|
||||
|
||||
// Export IDs.
|
||||
const (
|
||||
ExportTargetGlobal = "global"
|
||||
)
|
||||
|
||||
// Messages.
|
||||
var (
|
||||
MsgNone = ""
|
||||
MsgValid = "Import is valid."
|
||||
MsgSuccess = "Import successful."
|
||||
MsgRequireRestart = "Import successful. Restart required for setting to take effect."
|
||||
)
|
||||
|
||||
// ExportRequest is a request for an export.
|
||||
type ExportRequest struct {
|
||||
From string `json:"from"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
// ImportRequest is a request to import an export.
|
||||
type ImportRequest struct {
|
||||
// Where the export should be import to.
|
||||
Target string `json:"target"`
|
||||
// Only validate, but do not actually change anything.
|
||||
ValidateOnly bool `json:"validateOnly"`
|
||||
|
||||
RawExport string `json:"rawExport"`
|
||||
RawMime string `json:"rawMime"`
|
||||
}
|
||||
|
||||
// ImportResult is returned by successful import operations.
|
||||
type ImportResult struct {
|
||||
RestartRequired bool `json:"restartRequired"`
|
||||
ReplacesExisting bool `json:"replacesExisting"`
|
||||
ContainsUnknown bool `json:"containsUnknown"`
|
||||
}
|
||||
|
||||
// Errors.
|
||||
var (
|
||||
ErrMismatch = api.ErrorWithStatus(
|
||||
errors.New("the supplied export cannot be imported here"),
|
||||
http.StatusPreconditionFailed,
|
||||
)
|
||||
ErrSettingNotFound = api.ErrorWithStatus(
|
||||
errors.New("setting not found"),
|
||||
http.StatusPreconditionFailed,
|
||||
)
|
||||
ErrTargetNotFound = api.ErrorWithStatus(
|
||||
errors.New("import/export target does not exist"),
|
||||
http.StatusGone,
|
||||
)
|
||||
ErrUnchanged = api.ErrorWithStatus(
|
||||
errors.New("cannot export unchanged setting"),
|
||||
http.StatusGone,
|
||||
)
|
||||
ErrNotSettablePerApp = api.ErrorWithStatus(
|
||||
errors.New("cannot be set per app"),
|
||||
http.StatusGone,
|
||||
)
|
||||
ErrInvalidImportRequest = api.ErrorWithStatus(
|
||||
errors.New("invalid import request"),
|
||||
http.StatusUnprocessableEntity,
|
||||
)
|
||||
ErrInvalidSettingValue = api.ErrorWithStatus(
|
||||
errors.New("invalid setting value"),
|
||||
http.StatusUnprocessableEntity,
|
||||
)
|
||||
ErrInvalidProfileData = api.ErrorWithStatus(
|
||||
errors.New("invalid profile data"),
|
||||
http.StatusUnprocessableEntity,
|
||||
)
|
||||
ErrImportFailed = api.ErrorWithStatus(
|
||||
errors.New("import failed"),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
ErrExportFailed = api.ErrorWithStatus(
|
||||
errors.New("export failed"),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
)
|
||||
|
||||
func serializeExport(export any, ar *api.Request) (data []byte, err error) {
|
||||
// Get format.
|
||||
format := dsd.FormatFromAccept(ar.Header.Get("Accept"))
|
||||
|
||||
// Serialize and add checksum.
|
||||
switch format {
|
||||
case dsd.JSON:
|
||||
data, err = json.Marshal(export)
|
||||
if err == nil {
|
||||
data, err = filesig.AddJSONChecksum(data)
|
||||
}
|
||||
case dsd.YAML:
|
||||
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)
|
||||
}
|
||||
|
||||
// Set Content-Type HTTP Header.
|
||||
ar.ResponseHeader.Set("Content-Type", dsd.FormatToMimeType[dsd.YAML])
|
||||
|
||||
return exportData, nil
|
||||
}
|
||||
|
||||
func parseExport(request *ImportRequest, export any) error {
|
||||
format, err := dsd.MimeLoad([]byte(request.RawExport), request.RawMime, export)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: failed to parse export: %w", ErrInvalidImportRequest, err)
|
||||
}
|
||||
|
||||
// Verify checksum, if available.
|
||||
switch format {
|
||||
case dsd.JSON:
|
||||
err = filesig.VerifyJSONChecksum([]byte(request.RawExport))
|
||||
case dsd.YAML:
|
||||
err = filesig.VerifyYAMLChecksum([]byte(request.RawExport))
|
||||
default:
|
||||
// Checksums not supported.
|
||||
}
|
||||
if err != nil && !errors.Is(err, filesig.ErrChecksumMissing) {
|
||||
return fmt.Errorf("failed to verify checksum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user