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.
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.
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.
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:
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.
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.
I completely missed that CLPs, ACLs security issue. That is a valid point you made!
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) { }?
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 .
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.
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:
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.