How do you guys manage profile photos / files?

So I’m working on user profile photos and uploading them to my database.

However, I do wonder how do you guys manage the profile photos? As users change their photos, what do you do with the old ones? Do you have it programmatically deleted before saving a new one?

What happens to “orphaned” photos / Files? I can’t seem to find any documentation on how to manage these files via dashboard.

I do use a hosted backend (http://sashido.io) if that helps.

Cleaning up files without reference can be challenging, because a file can be referenced by more than one document. If you know that a file will only have one reference, you can delete the file using a Cloud Trigger, otherwise you’d probably need to implement a custom reference counter.

Currently, a developer has to implement their own custom solution, depending on their use case.

There have been attempts to address this, but none of them have resulted in a feature. The concept to address this is more or less clear, but someone would have to pick up the code and continue where others have left off.

See

1 Like

I was checking the User object on the afterSave trigger. And if profile_photo field has changed I delete the file. For example:

Parse.Cloud.afterSave('_User', ({original, object}) => {
  if (!original) return; //New user
  if (original.get('profile_photo').name() !== object.get('profile_photo').name()) {
    //Profile photo has changed.
    original.get('profile_photo').destroy({useMasterKey: true});
  }
});
4 Likes

I my case I use ParseSwift cloud function to save file:

struct CloudSaveFile: ParseCloud {
    
    //Return type of your Cloud Function
    typealias ReturnType = String
    //These are required for Object
    var functionJobName: String = CloudFunctionType.saveFile.rawValue
        
    init(name: String, data: [UInt8], ownerId: String) {
        self.name = name
        self.data = data
        self.ownerId = ownerId
    }
    
    var name: String?
    var data: [UInt8]?
    var ownerId: String?
    
}

I the picture data are encrypted with a key string equal to profile objectId so that the images cannot be viewed via public direct link in the storage (there is the profile objectId not known). The cloud function creates a reference in the MongoDB and returns to the client the new image url:

//File does not have final url and name in beforeFileSave, can't be used to save FileObj
//in afterFileSave I cannot remove metadata and the ownerId should be inaccessible 
//Using cloud function with passing ownerId as a parameter
Parse.Cloud.define("saveFile", async (request) => {
    //check if user is authenticated
    const token = { sessionToken: request.user.getSessionToken() };
    if (token == undefined) {
        throw `could not get valid session token`;
    }
    //save file to recieve url an unique name
    const passedName = request.params.name;
    const bytes = request.params.data;
    const file = new Parse.File(passedName, bytes);
    await file.save({sessionToken: token});
    
    //create FileObj entry with ownerID
    const fileObject = new Parse.Object('FileObj');
    fileObject.set('file', file); 
    fileObject.set('fn', file.name().split('_')[0]); //getting only uid part
    fileObject.set('ow', request.params.ownerId);
    await fileObject.save(null, { useMasterKey: true });

    //returning to the clien, what is the new image url
    return file.url();
});

It is then easy to work with object in the database, in this case FileObj. For example when it gets deleted it clears the related file from the storage:

//FileObj gets deleted first and triggers delete in storage
Parse.Cloud.beforeDelete("FileObj", async (request) => {
    const file = request.object.get("file");
    //request.log.info(`deleting file: ${file.name()}`);
    if (file == undefined) {
        return;
    } 
    try {        
        await file.destroy({ useMasterKey: true }); //here should be probably return instead await??
    } catch (e) {
        //TODO: mark somewhere for failed indexing attempts?
        throw `error deleting file in storage. File: ${file.name()}, error: ${e}`;
    }
    return;
});

Note to the idea behind encrypting all profile images with the given profile objectId as key:

  • Users that search for other users have to be already logged in to obtain both the other’s objectId and file urls
  • The storage direct links are not readable for any search engines as they do not know to what profile objectId the file belongs

I hope it helps. Let me know your thoughts as I myself am learning software development from scratch.

Thanks!

2 Likes

I like this. Simple.

I should have done this and stuck to one profile pic for now.

However, I went with pointer to ImageClass which holds a ParseFile.

This way, profile photos act much like a regular photo and will be there until the user decides to delete it.

Much like how Telegram app keeps a history of all your profile photos until you delete them.

Besides the already very helpful responses here I thought I would add that there are some useful step-by-step guides from Sashido and Back4App about deleting files:

nice thanks for the link! Missed that Sashido article. that works.

Would anyone want to add the beforeSave / beforeDelete triggers as workaround to delete files to the Parse docs? We are getting this question over and over again, and it surely could help developers to read this in the docs.

Manuel, please have a look at the commit, if it is what you had in your mind?

Thank you!

Although this is a neat solution, it’s assuming that the file is set to user.profile_photo. I would also add:

Parse.Cloud.beforeSaveFile(async ({file, user}) => {
  user.set('profile_photo', file);
  await user.save(null, {useMasterKey: true})
}, {
  requireUser: true
});

Just to be sure that the file is set.

I believe this is already covered pretty well in the Parse Cloud docs

Some thoughts:

  • Make sure you have CLPs, ACLs or a beforeSave set on FileObj class. Otherwise any user can query any FileObj and send a destroy command. This could allow malicious users to delete all your files.
  • You can use a Parse Cloud validator with requireUser: true instead of these lines:
const token = { sessionToken: request.user.getSessionToken() };
if (token == undefined) {
  throw `could not get valid session token`;
}

or:

if (!request.user) {
  throw 'Please login to call this function';
}
  • For this next section, it would make more logical sense to use a Parse Cloud File trigger (this is what they were made for :blush:) :
const passedName = request.params.name;
const bytes = request.params.data;
const file = new Parse.File(passedName, bytes);
await file.save({sessionToken: token});

Also I would recommend looking into Pointers and ACLs :blush:

A simpler way would be:

Parse.Cloud.afterSaveFile(async ({file, user}) => {
  const fileObject = new Parse.Object('FileObj');
  fileObject.set('file', file);
  fileObject.set('fn', file.name().split('_')[0]);
  fileObject.set('ow', user);
  await fileObject.save(null, { useMasterKey: true );
});
Parse.Cloud.beforeDeleteFile(async ({file, user}) => {
  const query = new Parse.Query('FileObject');
  query.equalTo('fn', file.name().split('_')[0]));
  query.equalTo('ow, user);
  const fileObject = await query.first({ useMasterKey: true });
  if (!fileObject) {
    throw 'You do not have permission to delete this file';
  }
  fileObject.destroy({useMasterKey: true});
});

Actually i’ve just noted the reason you didn’t use file triggers was:

//in afterFileSave I cannot remove metadata and the ownerId should be inaccessible 

Is this a Parse Server bug?

1 Like

Thank you for a great comment!

  1. I completely missed that CLPs, ACLs security issue. That is a valid point you made!
  2. I was running Parse 4.2 at the time of making this functions as back4app had 4.4 still marked as beta. Now they bumped it to 4.5 as beta so I will give it a try. Just out curiosity, is there any performance difference between Parse Cloud validator and checking if (!request.user) { }?
  3. The reason why I did not use Parse Cloud File trigger is are rather silly two.
  • First, I would need to fetch the file after save to receive a new URL (ParseSwift SDK)
  • In my case I have ownerId an objectId of a Profile object and not User. My initial plan was to user custom objectId for Profile to match the objectId with User, but ParseSwift SDKL does not support a mixed environment of generated/custom objectId what did prevent me from saving other objects with generated id. As I also realised now, the same method is used for Group and Event photos, where owner id is not an User but any other object.

As you mentioned, my example got inspired by the linked documentation, although I obviously did not get everything right. So I am not sure if my commit is good contribution.

Before moving to production, I would recommend trying to test some weak-points in your server design, such as querying objects when logged out, deleting objects when logged out, deleting other users’ objects, etc.

The Parse Cloud validator does if (!request.user) internally. It’s just a convenience tool to simplify your cloud code :blush:.

I would do something such as:

Parse.Cloud.afterSave(Parse.User, async ({ object, master }) => {
    if (master) {
        return;
    }
    if (!object.existed()) {
        const profile = new Parse.Object('Profile');
        const acl = new Parse.ACL(object);
        acl.setPublicReadAccess(true);
        profile.setACL(acl);
        await profile.save(null, {useMasterKey: true});
        object.set('profile', profile);
        await object.save(null, {useMasterKey: true});
    }
});
Parse.Cloud.beforeSave(Parse.User, ({object, master, original = new Parse.Object()}) => {
    if (master) {
        return;
    }
    if (object.get('profile') !== original.get('profile')) {
        // prevents user from unsetting or changing the profile pointer
        object.set('profile', original.get('profile');
    }
});

Then on frontend, use the pointer of current.profile to update a users’ profile.

And for good measure:

Parse.Cloud.afterSaveFile(async ({file, user}) => {
  const profile = user.get('profile');
  await profile.fetch(null, {useMasterKey: true});
  const photo = profile.get('photo');
  if (photo) {
     await photo.destroy(null, {useMasterKey: true});
  }
  profile.set('photo', file);
  await profile.save(null, {useMasterKey: true});
});

Pointers are your friend!! :blush:

2 Likes

Here’s a slightly different approach. We store all our images on AWS S3. The route we’ve taken is to use their pre-signed post requests, so that we can essentially upload to S3 directly from the browser. To this, you’ll need a simple Cloud function to generate the pre-signed post url:

export const createS3Upload = async (request: CloudRequest): Promise<CloudResponse> => {
    if (!request.params.fileName) {
        throw new Error(CLOUD_PARAMS_MISSING);
    }

    AWS.config.update({
        accessKeyId: config.AWS.accessKeyId,
        secretAccessKey: config.AWS.secretAccessKey,
        region: config.AWS.region,
    });

    const s3 = new AWS.S3({ apiVersion: "2006-03-01" });

    const uploadPolicy = s3.createPresignedPost({
        Bucket: config.AWS.s3ImageBucket,
        Fields: { key: request.params.fileName },
    });

    return {
        data: uploadPolicy,
    };
};

Once you’ve uploaded to S3 (from your browser), you can simply store the path and filename to the db. This way, you don’t have to worry about managing any of the media on the server.