Add export and import for profiles

This commit is contained in:
Daniel
2023-11-15 15:12:00 +01:00
parent beed574fa3
commit 602db080c5
13 changed files with 668 additions and 85 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -47,7 +47,7 @@ func TestDeriveProfileID(t *testing.T) {
})
// Check if fingerprint matches.
id := deriveProfileID(fps)
id := DeriveProfileID(fps)
assert.Equal(t, "PTSRP7rdCnmvdjRoPMTrtjj7qk7PxR1a9YdBWUGwnZXJh2", id)
}
}

View File

@@ -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) {

View File

@@ -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
}

69
profile/icons.go Normal file
View File

@@ -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.

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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.