Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unify Logic in Image APIs #647

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions backend/src/API/coffeeChatImageAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
setImage,
getImage,
getAllImagesForMember,
deleteImage,
deleteAllImagesForMember
} from '../utils/image-utils';

export const setCoffeeChatProofImage = async (name: string): Promise<string> => setImage(name);

export const getCoffeeChatProofImage = async (name: string): Promise<string> => getImage(name);

export const allCoffeeChatProofImagesForMember = async (
user: IdolMember
): Promise<readonly Image[]> => getAllImagesForMember(user, 'coffeeChatProofs');

export const deleteCoffeeChatProofImage = async (name: string): Promise<void> => {
deleteImage(name);
};

export const deleteCoffeeChatProofImagesForMember = async (user: IdolMember): Promise<void> => {
deleteAllImagesForMember(user, 'coffeeChatProofs');
};
103 changes: 14 additions & 89 deletions backend/src/API/teamEventsImageAPI.ts
Original file line number Diff line number Diff line change
@@ -1,97 +1,22 @@
import { bucket } from '../firebase';
import { getNetIDFromEmail } from '../utils/memberUtil';
import { NotFoundError } from '../utils/errors';
import {
setImage,
getImage,
getAllImagesForMember,
deleteImage,
deleteAllImagesForMember
} from '../utils/image-utils';

/**
* Sets TEC proof image for member
* @param name - the name of the image
* @param user - the member who made the request
* @returns a Promise to the signed URL to the image file
*/
export const setEventProofImage = async (name: string, user: IdolMember): Promise<string> => {
const file = bucket.file(`${name}.jpg`);
const signedURL = await file.getSignedUrl({
action: 'write',
version: 'v4',
expires: Date.now() + 15 * 60000 // 15 min
});
return signedURL[0];
};

/**
* Gets TEC proof image for member
* @param name - the name of the image
* @param user - the member who made the request
* @throws NotFoundError if the requested image does not exist
* @returns a Promise to the signed URL to the image file
*/
export const getEventProofImage = async (name: string, user: IdolMember): Promise<string> => {
const file = bucket.file(`${name}.jpg`);
const fileExists = await file.exists().then((result) => result[0]);
if (!fileExists) {
throw new NotFoundError(`The requested image (${name}) does not exist`);
}
const signedUrl = await file.getSignedUrl({
action: 'read',
expires: Date.now() + 15 * 60000
});
return signedUrl[0];
};
export const setEventProofImage = async (name: string): Promise<string> => setImage(name);

/**
* Gets all TEC proof images associated with the IdolMember
* @param user - the member who made the request
* @returns a Promise which results in an array of EventProofImage with file name and signed URL
*/
export const allEventProofImagesForMember = async (
user: IdolMember
): Promise<readonly EventProofImage[]> => {
const netId: string = getNetIDFromEmail(user.email);
const files = await bucket.getFiles({ prefix: `eventProofs/${netId}` });
const images = await Promise.all(
files[0].map(async (file) => {
const signedURL = await file.getSignedUrl({
action: 'read',
expires: Date.now() + 15 * 60000 // 15 min
});
const fileName = await file.getMetadata().then((data) => data[1].body.name);
return {
fileName,
url: signedURL[0]
};
})
);
export const getEventProofImage = async (name: string): Promise<string> => getImage(name);

images
.filter((image) => image.fileName.length > 'eventProofs/'.length)
.map((image) => ({
...image,
fileName: image.fileName.slice(image.fileName.indexOf('/') + 1)
}));

return images;
};
export const allEventProofImagesForMember = async (user: IdolMember): Promise<readonly Image[]> =>
getAllImagesForMember(user, 'eventProofs');

/**
* Deletes TEC proof image for member
* @param name - the name of the image
* @param user - the member who made the request
*/
export const deleteEventProofImage = async (name: string, user: IdolMember): Promise<void> => {
const imageFile = bucket.file(`${name}.jpg`);
await imageFile.delete();
export const deleteEventProofImage = async (name: string): Promise<void> => {
deleteImage(name);
};

/**
* Deletes all TEC proof images for given member
* @param user - the member who made the request
*/
export const deleteEventProofImagesForMember = async (user: IdolMember): Promise<void> => {
const netId: string = getNetIDFromEmail(user.email);
const files = await bucket.getFiles({ prefix: `eventProofs/${netId}` });
Promise.all(
files[0].map(async (file) => {
file.delete();
})
);
deleteAllImagesForMember(user, 'eventProofs');
};
33 changes: 27 additions & 6 deletions backend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ import {
regradeSubmissions,
updateSubmissions
} from './API/devPortfolioAPI';
import {
getCoffeeChatProofImage,
setCoffeeChatProofImage,
deleteCoffeeChatProofImage
} from './API/coffeeChatImageAPI';
import DPSubmissionRequestLogDao from './dao/DPSubmissionRequestLogDao';
import AdminsDao from './dao/AdminsDao';
import { sendMail } from './API/mailAPI';
Expand Down Expand Up @@ -288,6 +293,7 @@ loginCheckedDelete('/shoutout/:uuid', async (req, user) => {
return {};
});

// Coffee Chats
loginCheckedGet('/coffee-chat', async () => ({
coffeeChats: await getAllCoffeeChats()
}));
Expand Down Expand Up @@ -315,6 +321,20 @@ loginCheckedPut('/coffee-chat', async (req, user) => ({
coffeeChats: await updateCoffeeChat(req.body, user)
}));

// Coffee Chat Proof Image
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we unified this on the backend as well? So just have a generic /image/:name(*) (get), /image-signed-url/:name(*) (get), /image/:name(*) (delete) endpoints? I think the logic is the same across all the different use-cases anyways.

Instead of the utils, we'd have just three endpoints total (as opposed to 9)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should keep the image names the same though, so I would still make sure the images, when they're created, are prefixed correctly (i.e. eventProofs/, coffeeChatProofs/, member-image/)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I missed unifying this file as well. Will do that!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even better if it's just prefixed with /files to be super generic :D

loginCheckedGet('/coffee-chat-proof-image/:name(*)', async (req) => ({
url: await getCoffeeChatProofImage(req.params.name)
}));

loginCheckedGet('/coffee-chat-proof-image-signed-url/:name(*)', async (req) => ({
url: await setCoffeeChatProofImage(req.params.name)
}));

loginCheckedDelete('/coffee-chat-proof-image/:name(*)', async (req) => {
await deleteCoffeeChatProofImage(req.params.name);
return {};
});

// Pull from IDOL
loginCheckedPost('/pullIDOLChanges', (_, user) => requestIDOLPullDispatch(user));
loginCheckedGet('/getIDOLChangesPR', (_, user) => getIDOLChangesPR(user));
Expand Down Expand Up @@ -382,16 +402,17 @@ loginCheckedPost('/team-event-reminder', async (req, user) => ({
}));

// Team Events Proof Image
loginCheckedGet('/event-proof-image/:name(*)', async (req, user) => ({
url: await getEventProofImage(req.params.name, user)
loginCheckedGet('/event-proof-image/:name(*)', async (req) => ({
url: await getEventProofImage(req.params.name)
}));

// TODO: Modify this endpoint to /event-proof-image/* to be more RESTful
loginCheckedGet('/event-proof-image-signed-url/:name(*)', async (req, user) => ({
url: await setEventProofImage(req.params.name, user)
loginCheckedGet('/event-proof-image-signed-url/:name(*)', async (req) => ({
url: await setEventProofImage(req.params.name)
}));
loginCheckedDelete('/event-proof-image/:name(*)', async (req, user) => {
await deleteEventProofImage(req.params.name, user);

loginCheckedDelete('/event-proof-image/:name(*)', async (req) => {
await deleteEventProofImage(req.params.name);
return {};
});

Expand Down
97 changes: 97 additions & 0 deletions backend/src/utils/image-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { bucket } from '../firebase';
import { getNetIDFromEmail } from './memberUtil';
import { NotFoundError } from './errors';

/**
* Sets image for member
* @param name - the name of the image
* @returns a Promise to the signed URL to the image file
*/
export const setImage = async (name: string): Promise<string> => {
const file = bucket.file(`${name}.jpg`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on a side note - probably better off including the file extension in the actual file path itself instead of assuming they're all .jpg

const signedURL = await file.getSignedUrl({
action: 'write',
version: 'v4',
expires: Date.now() + 15 * 60000 // 15 min
});
return signedURL[0];
};

/**
* Gets image for member
* @param name - the name of the image
* @throws NotFoundError if the requested image does not exist
* @returns a Promise to the signed URL to the image file
*/
export const getImage = async (name: string): Promise<string> => {
const file = bucket.file(`${name}.jpg`);
const fileExists = await file.exists().then((result) => result[0]);
if (!fileExists) {
throw new NotFoundError(`The requested image (${name}) does not exist`);
}
const signedUrl = await file.getSignedUrl({
action: 'read',
expires: Date.now() + 15 * 60000
});
return signedUrl[0];
};
Comment on lines +10 to +37
Copy link
Collaborator

@henry-li-06 henry-li-06 Oct 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok so let's rename these while you're at it (ik i'm the one that gave them shitty names in the first place)

  • getWriteSignedURL
  • getReadSignedURL

or something a long those lines


/**
* Gets all images associated with the IdolMember
* @param user - the member who made the request
* @param type - the type of image (ex: eventProof, coffeeChatProof)
* @returns a Promise which results in an array of ProofImage with file name and signed URL
*/
export const getAllImagesForMember = async (
user: IdolMember,
type: string
): Promise<readonly Image[]> => {
const netId: string = getNetIDFromEmail(user.email);
const files = await bucket.getFiles({ prefix: `${type}/${netId}` });
const images = await Promise.all(
files[0].map(async (file) => {
const signedURL = await file.getSignedUrl({
action: 'read',
expires: Date.now() + 15 * 60000 // 15 min
});
const fileName = await file.getMetadata().then((data) => data[1].body.name);
return {
fileName,
url: signedURL[0]
};
})
);

images
.filter((image) => image.fileName.length > `${type}/`.length)
.map((image) => ({
...image,
fileName: image.fileName.slice(image.fileName.indexOf('/') + 1)
}));

return images;
};

/**
* Deletes image for member
* @param name - the name of the image
*/
export const deleteImage = async (name: string): Promise<void> => {
const imageFile = bucket.file(`${name}.jpg`);
await imageFile.delete();
};

/**
* Deletes all images for given member
* @param user - the member who made the request
* @param type - the type of image (ex: eventProof, coffeeChatProof)
*/
export const deleteAllImagesForMember = async (user: IdolMember, type: string): Promise<void> => {
const netId: string = getNetIDFromEmail(user.email);
const files = await bucket.getFiles({ prefix: `${type}/${netId}` });
Promise.all(
files[0].map(async (file) => {
file.delete();
})
);
};
2 changes: 1 addition & 1 deletion common-types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ interface TeamEvent extends TeamEventInfo {
readonly requests: TeamEventAttendance[];
}

interface EventProofImage {
interface Image {
readonly url: string;
readonly fileName: string;
}
Expand Down
48 changes: 8 additions & 40 deletions frontend/src/API/ImagesAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@ import APIWrapper from './APIWrapper';
import HeadshotPlaceholder from '../static/images/headshot-placeholder.png';

export default class ImagesAPI {
// member images
public static getMemberImage(email: string): Promise<string> {
const responseProm = APIWrapper.get(`${backendURL}/member-image/${email}`).then(
(res) => res.data
);
public static getImage(email: string, name: string): Promise<string> {
const responseProm = APIWrapper.get(`${backendURL}/${name}/${email}`).then((res) => res.data);

return responseProm.then((val) => {
if (val.error) {
Expand All @@ -17,48 +14,19 @@ export default class ImagesAPI {
});
}

private static getSignedURL(): Promise<string> {
const responseProm = APIWrapper.get(`${backendURL}/member-image-signedURL`).then(
(res) => res.data
);
private static getSignedURL(name: string): Promise<string> {
const responseProm = APIWrapper.get(`${backendURL}/${name}`).then((res) => res.data);
return responseProm.then((val) => val.url);
}

public static uploadMemberImage(body: Blob): Promise<void> {
return this.getSignedURL().then((url) => {
public static uploadImage(body: Blob, name: string): Promise<void> {
return this.getSignedURL(`${name}`).then((url) => {
const headers = { 'content-type': 'image/jpeg' };
APIWrapper.put(url, body, headers).then((res) => res.data);
});
}

// Event proof images
public static getEventProofImage(name: string): Promise<string> {
const responseProm = APIWrapper.get(`${backendURL}/event-proof-image/${name}`).then(
(res) => res.data
);
return responseProm.then((val) => {
if (val.error) {
return HeadshotPlaceholder.src;
}
return val.url;
});
}

private static getEventProofImageSignedURL(name: string): Promise<string> {
const responseProm = APIWrapper.get(`${backendURL}/event-proof-image-signed-url/${name}`).then(
(res) => res.data
);
return responseProm.then((val) => val.url);
}

public static uploadEventProofImage(body: Blob, name: string): Promise<void> {
return this.getEventProofImageSignedURL(name).then((url) => {
const headers = { 'content-type': 'image/jpeg' };
APIWrapper.put(url, body, headers).then((res) => res.data);
});
}

public static async deleteEventProofImage(name: string): Promise<void> {
await APIWrapper.delete(`${backendURL}/event-proof-image/${name}`);
public static async deleteImage(name: string): Promise<void> {
await APIWrapper.delete(`${backendURL}/${name}`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const TeamEventCreditReview = (props: {

useEffect(() => {
setLoading(true);
ImagesAPI.getEventProofImage(teamEventAttendance.image).then((url: string) => {
ImagesAPI.getImage(teamEventAttendance.image, 'event-proof-image').then((url: string) => {
setImage(url);
setLoading(false);
});
Expand Down Expand Up @@ -58,7 +58,7 @@ const TeamEventCreditReview = (props: {
headerMsg: 'Team Event Attendance Rejected!',
contentMsg: 'The team event attendance was successfully rejected!'
});
ImagesAPI.deleteEventProofImage(teamEventAttendance.image);
ImagesAPI.deleteImage(`event-proof-image/${teamEventAttendance.image}`);
Emitters.teamEventsUpdated.emit();
})
.catch((error) => {
Expand Down
Loading
Loading