diff --git a/backend/src/collections/image-db/image-db.service.ts b/backend/src/collections/image-db/image-db.service.ts index 0bf0268..f451974 100644 --- a/backend/src/collections/image-db/image-db.service.ts +++ b/backend/src/collections/image-db/image-db.service.ts @@ -19,11 +19,13 @@ export class ImageDBService { private imageDerivativeRepo: Repository, ) {} - public async create(): AsyncFailable { + public async create(userid: string): AsyncFailable { let imageEntity = new EImageBackend(); + imageEntity.user_id = userid; + imageEntity.created = new Date(); try { - imageEntity = await this.imageRepo.save(imageEntity); + imageEntity = await this.imageRepo.save(imageEntity, { reload: true }); } catch (e) { return Fail(e); } diff --git a/backend/src/collections/image-db/image-file-db.service.ts b/backend/src/collections/image-db/image-file-db.service.ts index 81bfb80..c60a390 100644 --- a/backend/src/collections/image-db/image-file-db.service.ts +++ b/backend/src/collections/image-db/image-file-db.service.ts @@ -86,10 +86,7 @@ export class ImageFileDBService { if (!found) return Fail('Image not found'); return Object.fromEntries( - types.map((type) => [ - type, - found.find((f) => f.type === type)?.mime, - ]), + types.map((type) => [type, found.find((f) => f.type === type)?.mime]), ); } catch (e) { return Fail(e); @@ -107,7 +104,7 @@ export class ImageFileDBService { imageDerivative.key = key; imageDerivative.mime = mime; imageDerivative.data = file; - imageDerivative.last_read_unix_sec = Math.floor(Date.now() / 1000); + imageDerivative.last_read = new Date(); try { return await this.imageDerivativeRepo.save(imageDerivative); @@ -126,9 +123,9 @@ export class ImageFileDBService { }); if (!derivative) return null; - const unix_seconds = Math.floor(Date.now() / 1000); - if (derivative.last_read_unix_sec > unix_seconds - A_DAY_IN_SECONDS) { - derivative.last_read_unix_sec = unix_seconds; + const yesterday = new Date(Date.now() - A_DAY_IN_SECONDS * 1000); + if (derivative.last_read > yesterday) { + derivative.last_read = new Date(); return await this.imageDerivativeRepo.save(derivative); } @@ -159,9 +156,8 @@ export class ImageFileDBService { olderThanSeconds: number, ): AsyncFailable { try { - const unix_seconds = Math.floor(Date.now() / 1000); const result = await this.imageDerivativeRepo.delete({ - last_read_unix_sec: LessThan(unix_seconds - olderThanSeconds), + last_read: LessThan(new Date()), }); return result.affected ?? 0; diff --git a/backend/src/layers/success/success.interceptor.ts b/backend/src/layers/success/success.interceptor.ts index f1ecbb7..9d52414 100644 --- a/backend/src/layers/success/success.interceptor.ts +++ b/backend/src/layers/success/success.interceptor.ts @@ -66,7 +66,7 @@ export class SuccessInterceptor implements NestInterceptor { const parseResult = schema.safeParse(data); if (!parseResult.success) { this.logger.warn( - `Function ${context.getHandler().name} failed validation`, + `Function ${context.getHandler().name} failed validation: ${parseResult.error}`, ); throw new InternalServerErrorException( 'Server produced invalid response', diff --git a/backend/src/managers/image/image.service.ts b/backend/src/managers/image/image.service.ts index 3e37d74..9062fec 100644 --- a/backend/src/managers/image/image.service.ts +++ b/backend/src/managers/image/image.service.ts @@ -37,12 +37,6 @@ export class ImageManagerService { return await this.imagesService.findOne(id); } - // Image data buffer is not included by default, this also returns that buffer - // Dont send to client, keep in backend - public async retrieveComplete(id: string): AsyncFailable { - return await this.imagesService.findOne(id); - } - public async upload( image: Buffer, userid: string, @@ -62,7 +56,7 @@ export class ImageManagerService { if (HasFailed(processResult)) return processResult; // Save processed to db - const imageEntity = await this.imagesService.create(); + const imageEntity = await this.imagesService.create(userid); if (HasFailed(imageEntity)) return imageEntity; const imageFileEntity = await this.imageFilesService.setFile( diff --git a/backend/src/models/entities/image-derivative.entity.ts b/backend/src/models/entities/image-derivative.entity.ts index c4a34a7..9e866fe 100644 --- a/backend/src/models/entities/image-derivative.entity.ts +++ b/backend/src/models/entities/image-derivative.entity.ts @@ -18,7 +18,7 @@ export class EImageDerivativeBackend { mime: string; @Column({ name: 'last_read', nullable: false }) - last_read_unix_sec: number; + last_read: Date; // Binary data @Column({ type: 'bytea', nullable: false }) diff --git a/backend/src/models/entities/image.entity.ts b/backend/src/models/entities/image.entity.ts index 2a58e60..bf19f21 100644 --- a/backend/src/models/entities/image.entity.ts +++ b/backend/src/models/entities/image.entity.ts @@ -1,8 +1,18 @@ import { EImage } from 'picsur-shared/dist/entities/image.entity'; -import { Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class EImageBackend implements EImage { @PrimaryGeneratedColumn('uuid') id: string; + + @Column({ + nullable: false, + }) + user_id: string; + + @Column({ + nullable: false + }) + created: Date; } diff --git a/backend/src/models/entities/user.entity.ts b/backend/src/models/entities/user.entity.ts index c6bd5ca..1402f3e 100644 --- a/backend/src/models/entities/user.entity.ts +++ b/backend/src/models/entities/user.entity.ts @@ -13,7 +13,7 @@ type OverriddenEUser = z.infer; @Entity() export class EUserBackend implements OverriddenEUser { @PrimaryGeneratedColumn('uuid', {}) - id?: string; + id: string; @Index() @Column({ nullable: false, unique: true }) diff --git a/backend/src/routes/image/image.controller.ts b/backend/src/routes/image/image.controller.ts index 2dd9eea..80eec0d 100644 --- a/backend/src/routes/image/image.controller.ts +++ b/backend/src/routes/image/image.controller.ts @@ -7,15 +7,16 @@ import { NotFoundException, Post, Query, - Res + Res, } from '@nestjs/common'; import { FastifyReply } from 'fastify'; import { ImageMetaResponse, ImageRequestParams, - ImageUploadResponse + ImageUploadResponse, } from 'picsur-shared/dist/dto/api/image.dto'; import { HasFailed } from 'picsur-shared/dist/types'; +import { UsersService } from '../../collections/user-db/user-db.service'; import { ImageFullIdParam } from '../../decorators/image-id/image-full-id.decorator'; import { ImageIdParam } from '../../decorators/image-id/image-id.decorator'; import { MultiPart } from '../../decorators/multipart/multipart.decorator'; @@ -26,6 +27,7 @@ import { ImageManagerService } from '../../managers/image/image.service'; import { ImageFullId } from '../../models/constants/image-full-id.const'; import { Permission } from '../../models/constants/permissions.const'; import { ImageUploadDto } from '../../models/dto/image-upload.dto'; +import { EUserBackend2EUser } from '../../models/transformers/user.transformer'; // This is the only controller with CORS enabled @Controller('i') @@ -33,7 +35,10 @@ import { ImageUploadDto } from '../../models/dto/image-upload.dto'; export class ImageController { private readonly logger = new Logger('ImageController'); - constructor(private readonly imagesService: ImageManagerService) {} + constructor( + private readonly imagesService: ImageManagerService, + private readonly userService: UsersService, + ) {} @Get(':id') async getImage( @@ -96,13 +101,20 @@ export class ImageController { throw new NotFoundException('Could not find image'); } - const fileMimes = await this.imagesService.getAllFileMimes(id); + const [fileMimes, imageUser] = await Promise.all([ + this.imagesService.getAllFileMimes(id), + this.userService.findOne(image.user_id), + ]); if (HasFailed(fileMimes)) { this.logger.warn(fileMimes.getReason()); throw new InternalServerErrorException('Could not get image mime'); } + if (HasFailed(imageUser)) { + this.logger.warn(imageUser.getReason()); + throw new InternalServerErrorException('Could not get image user'); + } - return { image, fileMimes }; + return { image, user: EUserBackend2EUser(imageUser), fileMimes }; } @Post() diff --git a/backend/src/routes/image/image.module.ts b/backend/src/routes/image/image.module.ts index 6626c9f..2e91ddb 100644 --- a/backend/src/routes/image/image.module.ts +++ b/backend/src/routes/image/image.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { UsersModule } from '../../collections/user-db/user-db.module'; import { DecoratorsModule } from '../../decorators/decorators.module'; import { ImageManagerModule } from '../../managers/image/image.module'; import { ImageController } from './image.controller'; @Module({ - imports: [ImageManagerModule, DecoratorsModule], + imports: [ImageManagerModule, UsersModule, DecoratorsModule], controllers: [ImageController], }) export class ImageModule {} diff --git a/frontend/package.json b/frontend/package.json index 487ef6f..ee4f740 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "bootstrap": "^5.1.3", "fuse.js": "^6.5.3", "jwt-decode": "^3.1.2", + "moment": "^2.29.3", "ngx-auto-unsubscribe-decorator": "^1.1.0", "ngx-dropzone": "^3.1.0", "picsur-shared": "*", diff --git a/frontend/src/app/routes/view/view.component.html b/frontend/src/app/routes/view/view.component.html index 905b0a4..dc74247 100644 --- a/frontend/src/app/routes/view/view.component.html +++ b/frontend/src/app/routes/view/view.component.html @@ -1,7 +1,11 @@
-

Uploaded Image

+

Image uploaded by {{imageUser?.username}}

+
+ +
+

{{(timeAgo | async)}}

diff --git a/frontend/src/app/routes/view/view.component.ts b/frontend/src/app/routes/view/view.component.ts index f4701b1..803719b 100644 --- a/frontend/src/app/routes/view/view.component.ts +++ b/frontend/src/app/routes/view/view.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import moment from 'moment'; import { ImageLinks } from 'picsur-shared/dist/dto/image-links.dto'; import { AnimMime, @@ -10,10 +11,13 @@ import { SupportedImageMimes, SupportedMimeCategory } from 'picsur-shared/dist/dto/mimes.dto'; +import { EImage } from 'picsur-shared/dist/entities/image.entity'; +import { EUser } from 'picsur-shared/dist/entities/user.entity'; import { HasFailed, HasSuccess } from 'picsur-shared/dist/types'; import { UUIDRegex } from 'picsur-shared/dist/util/common-regex'; import { ParseMime } from 'picsur-shared/dist/util/parse-mime'; import { ImageService } from 'src/app/services/api/image.service'; +import { rxjs_poll } from 'src/app/util/poll'; import { UtilService } from 'src/app/util/util-module/util.service'; @Component({ @@ -45,6 +49,14 @@ export class ViewComponent implements OnInit { public previewLink = ''; public imageLinks = new ImageLinks(); + public image: EImage | null = null; + public imageUser: EUser | null = null; + + public timeAgo = rxjs_poll( + 1000, + (() => moment(this.image?.created).fromNow()).bind(this) + ); + async ngOnInit() { const params = this.route.snapshot.paramMap; @@ -64,6 +76,9 @@ export class ViewComponent implements OnInit { this.hasOriginal = metadata.fileMimes.original !== undefined; + this.imageUser = metadata.user; + this.image = metadata.image; + const masterMime = ParseMime(metadata.fileMimes.master); if (HasSuccess(masterMime)) { this.masterMime = masterMime; diff --git a/frontend/src/app/util/poll.ts b/frontend/src/app/util/poll.ts new file mode 100644 index 0000000..d88e8d1 --- /dev/null +++ b/frontend/src/app/util/poll.ts @@ -0,0 +1,5 @@ +import { map, Observable, timer } from 'rxjs'; + +export function rxjs_poll(period: number, action: () => T): Observable { + return timer(0, 1000).pipe(map(() => action())); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 76a6ed9..0e40c42 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -9,4 +9,4 @@ if (Environment.production) { platformBrowserDynamic() .bootstrapModule(AppModule) - .catch((err) => console.error(err)); + .catch(console.error); diff --git a/shared/src/dto/api/image.dto.ts b/shared/src/dto/api/image.dto.ts index fbc1f3b..556b787 100644 --- a/shared/src/dto/api/image.dto.ts +++ b/shared/src/dto/api/image.dto.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { EImageSchema } from '../../entities/image.entity'; +import { EUserSchema } from '../../entities/user.entity'; import { createZodDto } from '../../util/create-zod-dto'; import { ImageFileType } from '../image-file-types.dto'; @@ -32,6 +33,7 @@ export class ImageRequestParams extends createZodDto( export const ImageMetaResponseSchema = z.object({ image: EImageSchema, + user: EUserSchema, fileMimes: z.object({ [ImageFileType.MASTER]: z.string(), [ImageFileType.ORIGINAL]: z.union([z.string(), z.undefined()]), diff --git a/shared/src/entities/image.entity.ts b/shared/src/entities/image.entity.ts index d1624ba..9825800 100644 --- a/shared/src/entities/image.entity.ts +++ b/shared/src/entities/image.entity.ts @@ -3,5 +3,7 @@ import { IsEntityID } from '../validators/entity-id.validator'; export const EImageSchema = z.object({ id: IsEntityID(), + user_id: z.string(), + created: z.preprocess((data: any) => new Date(data), z.date()), }); export type EImage = z.infer; diff --git a/shared/src/entities/user.entity.ts b/shared/src/entities/user.entity.ts index 669a13c..69be745 100644 --- a/shared/src/entities/user.entity.ts +++ b/shared/src/entities/user.entity.ts @@ -11,7 +11,7 @@ export const SimpleUserSchema = z.object({ export type SimpleUser = z.infer; export const EUserSchema = z.object({ - id: IsEntityID().optional(), + id: IsEntityID(), username: IsUsername(), roles: IsStringList(), hashedPassword: z.undefined(), diff --git a/yarn.lock b/yarn.lock index c42f301..74b67cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1331,7 +1331,8 @@ secure-json-parse "^2.4.0" stream-wormhole "^1.1.0" -"@fastify/static@^5.0.0", fastify-static@^4.7.0, "fastify-static@npm:@fastify/static": +"@fastify/static@^5.0.0", "fastify-static@npm:@fastify/static": + name fastify-static version "5.0.1" resolved "https://registry.yarnpkg.com/@fastify/static/-/static-5.0.1.tgz#6bb6b94c2b51d9fafb06261b79ed5da5038dd5a1" integrity sha512-0lReUKWVOt2i5i1KoBLYsbcMBthq5eiEoUQrFceoXJkCv+OyA0iLl6hGxcmnMRxLsl/Netrzt1NNOpg4muCcuw== @@ -3957,6 +3958,27 @@ fastify-plugin@^3.0.0: resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-3.0.1.tgz#79e84c29f401020f38b524f59f2402103fd21ed2" integrity sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA== +"fastify-static-deprecated@npm:fastify-static@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/fastify-static/-/fastify-static-4.6.1.tgz#687131da76f1d4391fb8b47f71ea2118cdc85803" + integrity sha512-vy7N28U4AMhuOim12ZZWHulEE6OQKtzZbHgiB8Zj4llUuUQXPka0WHAQI3njm1jTCx4W6fixUHfpITxweMtAIA== + dependencies: + content-disposition "^0.5.3" + encoding-negotiator "^2.0.1" + fastify-plugin "^3.0.0" + glob "^7.1.4" + p-limit "^3.1.0" + readable-stream "^3.4.0" + send "^0.17.1" + +fastify-static@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/fastify-static/-/fastify-static-4.7.0.tgz#e802658d69c1dcddb380b9afc2456d467a3494be" + integrity sha512-zZhCfJv/hkmud2qhWqpU3K9XVAuy3+IV8Tp9BC5J5U+GyA2XwoB6h8lh9GqpEIqdXOw01WyWQllV7dOWVyAlXg== + dependencies: + fastify-static-deprecated "npm:fastify-static@4.6.1" + process-warning "^1.0.0" + fastify@3.28.0: version "3.28.0" resolved "https://registry.yarnpkg.com/fastify/-/fastify-3.28.0.tgz#14d939a2f176b82af1094de7abcb0b2d83bcff8f" @@ -5333,7 +5355,12 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" -minimist@1.2.6, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6, "minimist@npm:minimist-lite": +minimist@1.2.6, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== + +"minimist@npm:minimist-lite": version "2.2.1" resolved "https://registry.yarnpkg.com/minimist-lite/-/minimist-lite-2.2.1.tgz#abb71db2c9b454d7cf4496868c03e9802de9934d" integrity sha512-RSrWIRWGYoM2TDe102s7aIyeSipXMIXKb1fSHYx1tAbxAV0z4g2xR6ra3oPzkTqFb0EIUz1H3A/qvYYeDd+/qQ== @@ -5417,6 +5444,11 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +moment@^2.29.3: + version "2.29.3" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.3.tgz#edd47411c322413999f7a5940d526de183c031f3" + integrity sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"