Add export and import for profiles
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestDeriveProfileID(t *testing.T) {
|
||||
})
|
||||
|
||||
// Check if fingerprint matches.
|
||||
id := deriveProfileID(fps)
|
||||
id := DeriveProfileID(fps)
|
||||
assert.Equal(t, "PTSRP7rdCnmvdjRoPMTrtjj7qk7PxR1a9YdBWUGwnZXJh2", id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
69
profile/icons.go
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user