From 0b44f10a567f5b90de9f22636ec32533f6a44178 Mon Sep 17 00:00:00 2001 From: Stepan Usatiuk Date: Thu, 15 Oct 2020 13:59:40 +0300 Subject: [PATCH] show thumbnails instead of full size --- frontend/src/Photos/PhotoCard.tsx | 18 +++++++-- frontend/src/redux/api/photos.ts | 6 +++ src/entity/Photo.ts | 56 ++++++++++++++++++++++++++-- src/routes/photos.ts | 14 ++++++- src/tests/integration/photos.test.ts | 16 ++++++++ src/util.ts | 8 ++++ 6 files changed, 111 insertions(+), 7 deletions(-) diff --git a/frontend/src/Photos/PhotoCard.tsx b/frontend/src/Photos/PhotoCard.tsx index caa725e..b1faa2e 100644 --- a/frontend/src/Photos/PhotoCard.tsx +++ b/frontend/src/Photos/PhotoCard.tsx @@ -1,11 +1,18 @@ -import { Card, ContextMenuTarget, Menu, MenuItem } from "@blueprintjs/core"; +import { + Card, + ContextMenuTarget, + Menu, + MenuItem, + Spinner, +} from "@blueprintjs/core"; import * as React from "react"; import { IPhotoReqJSON } from "~../../src/entity/Photo"; -import { getPhotoImgPath } from "~redux/api/photos"; +import { getPhotoImgPath, getPhotoThumbPath } from "~redux/api/photos"; import { showDeletionToast } from "~AppToaster"; import { Dispatch } from "redux"; import { photoDeleteCancel, photoDeleteStart } from "~redux/photos/actions"; import { connect } from "react-redux"; +import { LoadingStub } from "~LoadingStub"; export interface IPhotoCardComponentProps { photo: IPhotoReqJSON; @@ -35,6 +42,7 @@ export class PhotoCardComponent extends React.PureComponent< } */ public render(): JSX.Element { + const isUploaded = this.props.photo.uploaded; return ( - + {isUploaded ? ( + + ) : ( + + )} ); } diff --git a/frontend/src/redux/api/photos.ts b/frontend/src/redux/api/photos.ts index 2979dc1..bcf408e 100644 --- a/frontend/src/redux/api/photos.ts +++ b/frontend/src/redux/api/photos.ts @@ -12,6 +12,12 @@ export function getPhotoImgPath(photo: IPhotoReqJSON): string { return `${apiRoot}/photos/showByID/${photo.id}/${photo.accessToken}`; } +export function getPhotoThumbPath(photo: IPhotoReqJSON, size: number): string { + return `${apiRoot}/photos/showByID/${photo.id}/${ + photo.accessToken + }?size=${size.toString()}`; +} + export async function fetchPhotosList(): Promise { return fetchJSONAuth("/photos/list", "GET"); } diff --git a/src/entity/Photo.ts b/src/entity/Photo.ts index 1776359..92b5d35 100644 --- a/src/entity/Photo.ts +++ b/src/entity/Photo.ts @@ -21,12 +21,15 @@ import { isAlphanumeric, IsAlphanumeric, IsHash, + IsIn, IsMimeType, + isNumber, Length, Matches, validateOrReject, } from "class-validator"; import { config } from "~config"; +import { resizeTo } from "~util"; export interface IPhotoJSON { id: number; @@ -37,12 +40,15 @@ export interface IPhotoJSON { createdAt: number; editedAt: number; shotAt: number; + uploaded: boolean; } export interface IPhotoReqJSON extends IPhotoJSON { accessToken: string; } +export const ThumbSizes = ["512", "1024", "2048"]; + @Entity() @Index(["hash", "size", "user"], { unique: true }) export class Photo extends BaseEntity { @@ -63,6 +69,9 @@ export class Photo extends BaseEntity { @IsMimeType() public format: string; + @Column({ type: "set", enum: ThumbSizes, default: [] }) + public generatedThumbs: string[]; + @Column({ type: "varchar", length: 500 }) public accessToken: string; @@ -87,10 +96,20 @@ export class Photo extends BaseEntity { }`; } + private getThumbFileName(size: number): string { + return `${this.user.id.toString()}-${this.hash}-${this.size}-${size}.${ + mime.extension(this.format) as string + }`; + } + public getPath(): string { return path.join(this.user.getDataPath(), this.getFileName()); } + private getThumbPath(size: number): string { + return path.join(this.user.getDataPath(), this.getThumbFileName(size)); + } + @BeforeInsert() async beforeInsertValidate(): Promise { return validateOrReject(this); @@ -105,6 +124,12 @@ export class Photo extends BaseEntity { async cleanupFiles(): Promise { try { await fs.unlink(this.getPath()); + await Promise.all( + this.generatedThumbs.map( + async (size) => + await fs.unlink(this.getThumbPath(parseInt(size))), + ), + ); } catch (e) { if (e.code !== "ENOENT") { throw e; @@ -121,6 +146,26 @@ export class Photo extends BaseEntity { } } + private async generateThumbnail(size: number): Promise { + if (!(await this.isUploaded())) { + return; + } + await resizeTo(this.getPath(), this.getThumbPath(size), size); + this.generatedThumbs.push(size.toString()); + await this.save(); + } + + public async getReadyThumbnailPath(size: number): Promise { + if (!ThumbSizes.includes(size.toString())) { + throw new Error("Wrong thumbnail size"); + } + const path = this.getThumbPath(size); + if (!this.generatedThumbs.includes(size.toString())) { + await this.generateThumbnail(size); + } + return path; + } + constructor(user: User, hash: string, size: string, format: string) { super(); this.createdAt = new Date(); @@ -132,6 +177,7 @@ export class Photo extends BaseEntity { this.format = format; this.size = size; this.user = user; + this.generatedThumbs = []; } public async getJWTToken(): Promise { @@ -141,7 +187,7 @@ export class Photo extends BaseEntity { if (tokenExpiryOld - now - 60 * 10 * 1000 > 0) { return this.accessToken; } else { - const token = jwt.sign(this.toJSON(), config.jwtSecret, { + const token = jwt.sign(await this.toJSON(), config.jwtSecret, { expiresIn: "1h", algorithm: "HS256", }); @@ -152,7 +198,10 @@ export class Photo extends BaseEntity { } } - public toJSON(): IPhotoJSON { + public async toJSON(): Promise { + if (!isNumber(this.user.id)) { + throw new Error("User not loaded"); + } return { id: this.id, user: this.user.id, @@ -162,11 +211,12 @@ export class Photo extends BaseEntity { createdAt: this.createdAt.getTime(), editedAt: this.editedAt.getTime(), shotAt: this.shotAt.getTime(), + uploaded: await this.isUploaded(), }; } public async toReqJSON(): Promise { const token = await this.getJWTToken(); - return { ...this.toJSON(), accessToken: token }; + return { ...(await this.toJSON()), accessToken: token }; } } diff --git a/src/routes/photos.ts b/src/routes/photos.ts index d7b1326..2338af3 100644 --- a/src/routes/photos.ts +++ b/src/routes/photos.ts @@ -30,7 +30,7 @@ photosRouter.post("/photos/new", async (ctx) => { return; } - const photo = new Photo(user.id, hash, size, format); + const photo = new Photo(user, hash, size, format); try { await photo.save(); @@ -241,6 +241,12 @@ photosRouter.get("/photos/showByID/:id/:token", async (ctx) => { return; } + if (ctx.request.query["size"]) { + const size = parseInt(ctx.request.query["size"]); + await send(ctx, await photo.getReadyThumbnailPath(size)); + return; + } + await send(ctx, photo.getPath()); }); @@ -266,6 +272,12 @@ photosRouter.get("/photos/showByID/:id", async (ctx) => { return; } + if (ctx.request.query["size"]) { + const size = parseInt(ctx.request.query["size"]); + await send(ctx, await photo.getReadyThumbnailPath(size)); + return; + } + await send(ctx, photo.getPath()); }); diff --git a/src/tests/integration/photos.test.ts b/src/tests/integration/photos.test.ts index 03e43a9..19eccc6 100644 --- a/src/tests/integration/photos.test.ts +++ b/src/tests/integration/photos.test.ts @@ -82,6 +82,18 @@ describe("photos", function () { ); }); + it("should show a thumbnail", async function () { + const response = await request(callback) + .get(`/photos/showByID/${seed.dogPhoto.id}?size=512`) + .set({ + Authorization: `Bearer ${seed.user2.toJWT()}`, + }) + .expect(200); + expect(parseInt(response.header["content-length"])).to.be.lessThan( + dogFileSize, + ); + }); + it("should show a photo using access token", async function () { const listResp = await request(callback) .get(`/photos/list`) @@ -524,6 +536,9 @@ describe("photos", function () { it("should delete a photo", async function () { const photoPath = seed.dogPhoto.getPath(); + const photoSmallThumbPath = await seed.dogPhoto.getReadyThumbnailPath( + 512, + ); const response = await request(callback) .delete(`/photos/byID/${seed.dogPhoto.id}`) .set({ @@ -537,6 +552,7 @@ describe("photos", function () { try { await fs.access(photoPath, fsConstants.F_OK); + await fs.access(photoSmallThumbPath, fsConstants.F_OK); assert(false); } catch (e) { assert(true); diff --git a/src/util.ts b/src/util.ts index c71ff87..a92cec6 100644 --- a/src/util.ts +++ b/src/util.ts @@ -18,6 +18,14 @@ export async function getSize(file: string): Promise { return `${metadata.width}x${metadata.height}`; } +export async function resizeTo( + inPath: string, + outPath: string, + size: number, +): Promise { + await sharp(inPath).resize(size, size).withMetadata().toFile(outPath); +} + // eslint-disable-next-line @typescript-eslint/no-misused-promises export const getHashSync: (file: string) => string = deasync(getHash); // eslint-disable-next-line @typescript-eslint/no-misused-promises