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