Skip to content

Commit

Permalink
Add export and import for profiles
Browse files Browse the repository at this point in the history
  • Loading branch information
dhaavi committed Nov 15, 2023
1 parent beed574 commit 602db08
Show file tree
Hide file tree
Showing 13 changed files with 668 additions and 85 deletions.
91 changes: 91 additions & 0 deletions profile/api.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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"

Check failure on line 133 in profile/api.go

View workflow job for this annotation

GitHub Actions / Linter

string `jpg` has 4 occurrences, make it a constant (goconst)
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
}
6 changes: 4 additions & 2 deletions profile/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
10 changes: 6 additions & 4 deletions profile/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {

Check failure on line 174 in profile/fingerprint.go

View workflow job for this annotation

GitHub Actions / Linter

exported func ParseFingerprints returns unexported type *github.com/safing/portmaster/profile.parsedFingerprints, which can be annoying to use (golint)
parsed = &parsedFingerprints{}

// Add deprecated LinkedPath to fingerprints, if they are empty.
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion profile/fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func TestDeriveProfileID(t *testing.T) {
})

// Check if fingerprint matches.
id := deriveProfileID(fps)
id := DeriveProfileID(fps)
assert.Equal(t, "PTSRP7rdCnmvdjRoPMTrtjj7qk7PxR1a9YdBWUGwnZXJh2", id)
}
}
14 changes: 7 additions & 7 deletions profile/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)
}
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions profile/icon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
69 changes: 69 additions & 0 deletions profile/icons.go
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion profile/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions profile/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down

0 comments on commit 602db08

Please sign in to comment.