add ability to make image expire

This commit is contained in:
rubikscraft 2022-09-09 15:13:56 +02:00
parent 08af514758
commit e0e804d27d
No known key found for this signature in database
GPG key ID: 3570A2BB18A63D9F
20 changed files with 247 additions and 29 deletions

View file

@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TypeOrmOptionsFactory } from '@nestjs/typeorm'; import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';
import { ParseInt, ParseString } from 'picsur-shared/dist/util/parse-simple'; import { ParseInt, ParseString } from 'picsur-shared/dist/util/parse-simple';
import { EntityList } from '../../database/entities'; import { EntityList } from '../../database/entities';
import { MigrationList } from '../../database/migrations'; import { MigrationList } from '../../database/migrations';
@ -52,19 +52,21 @@ export class TypeOrmConfigService implements TypeOrmOptionsFactory {
const varOptions = this.getTypeOrmServerOptions(); const varOptions = this.getTypeOrmServerOptions();
return { return {
type: 'postgres' as 'postgres', type: 'postgres' as 'postgres',
synchronize: false, //!this.hostService.isProduction(), synchronize: !this.hostService.isProduction(),
migrationsRun: true, migrationsRun: true,
entities: EntityList, entities: EntityList,
migrations: MigrationList, migrations: MigrationList,
useUTC: true,
cli: { cli: {
migrationsDir: 'src/database/migrations', migrationsDir: 'src/database/migrations',
entitiesDir: 'src/database/entities', entitiesDir: 'src/database/entities',
}, },
...varOptions, ...varOptions,
}; } as TypeOrmModuleOptions;
} }
} }

View file

@ -41,13 +41,13 @@ export class EApiKeyBackend<
name: string; name: string;
@Column({ @Column({
type: 'timestamp', type: 'timestamptz',
nullable: false, nullable: false,
}) })
created: Date; created: Date;
@Column({ @Column({
type: 'timestamp', type: 'timestamptz',
nullable: true, nullable: true,
}) })
last_used: Date; last_used: Date;

View file

@ -36,7 +36,11 @@ export class EImageDerivativeBackend {
@Column({ nullable: false }) @Column({ nullable: false })
filetype: string; filetype: string;
@Column({ type: 'timestamp', name: 'last_read', nullable: false }) @Column({
type: 'timestamptz',
name: 'last_read',
nullable: false,
})
last_read: Date; last_read: Date;
// Binary data // Binary data

View file

@ -15,7 +15,7 @@ export class EImageBackend implements EImage {
user_id: string; user_id: string;
@Column({ @Column({
type: 'timestamp', type: 'timestamptz',
nullable: false, nullable: false,
}) })
created: Date; created: Date;
@ -27,7 +27,7 @@ export class EImageBackend implements EImage {
file_name: string; file_name: string;
@Column({ @Column({
type: 'timestamp', type: "timestamptz",
nullable: true, nullable: true,
}) })
expires_at: Date | null; expires_at: Date | null;

View file

@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class V040D1662728275448 implements MigrationInterface {
name = 'V040D1662728275448'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "e_api_key_backend" ALTER COLUMN "created" SET DATA TYPE TIMESTAMP WITH TIME ZONE, ALTER COLUMN "created" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "e_api_key_backend" ALTER COLUMN "last_used" SET DATA TYPE TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "e_image_backend" ALTER COLUMN "created" SET DATA TYPE TIMESTAMP WITH TIME ZONE, ALTER COLUMN "created" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "e_image_backend" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "e_image_derivative_backend" ALTER COLUMN "last_read" SET DATA TYPE TIMESTAMP WITH TIME ZONE, ALTER COLUMN "last_read" SET NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "e_image_derivative_backend" ALTER COLUMN "last_read" SET DATA TYPE TIMESTAMP, ALTER COLUMN "last_read" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "e_image_backend" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "e_image_backend" ALTER COLUMN "created" SET DATA TYPE TIMESTAMP, ALTER COLUMN "created" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "e_api_key_backend" ALTER COLUMN "last_used" SET DATA TYPE TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "e_api_key_backend" ALTER COLUMN "created" SET DATA TYPE TIMESTAMP, ALTER COLUMN "created" SET NOT NULL`);
}
}

View file

@ -3,6 +3,7 @@ import { V032A1662029904716 } from './1662029904716-V_0_3_2_a';
import { V040A1662314197741 } from './1662314197741-V_0_4_0_a'; import { V040A1662314197741 } from './1662314197741-V_0_4_0_a';
import { V040B1662485374471 } from './1662485374471-V_0_4_0_b'; import { V040B1662485374471 } from './1662485374471-V_0_4_0_b';
import { V040C1662535484200 } from './1662535484200-V_0_4_0_c'; import { V040C1662535484200 } from './1662535484200-V_0_4_0_c';
import { V040D1662728275448 } from './1662728275448-V_0_4_0_d';
export const MigrationList: Function[] = [ export const MigrationList: Function[] = [
V030A1661692206479, V030A1661692206479,
@ -10,4 +11,5 @@ export const MigrationList: Function[] = [
V040A1662314197741, V040A1662314197741,
V040B1662485374471, V040B1662485374471,
V040C1662535484200, V040C1662535484200,
V040D1662728275448,
]; ];

View file

@ -17,7 +17,7 @@ async function createDataSource() {
const configFactory = app.get(TypeOrmConfigService); const configFactory = app.get(TypeOrmConfigService);
const config = await configFactory.createTypeOrmOptions(); const config = await configFactory.createTypeOrmOptions();
return new DataSource(config); return new DataSource(config as any);
} }
export default createDataSource(); export default createDataSource();

View file

@ -61,7 +61,7 @@ export class ImageManagerModule implements OnModuleInit, OnModuleDestroy {
const result = await this.imageFileDB.cleanupDerivatives(after_ms / 1000); const result = await this.imageFileDB.cleanupDerivatives(after_ms / 1000);
if (HasFailed(result)) { if (HasFailed(result)) {
this.logger.warn(`Failed to cleanup derivatives`); this.logger.warn(result.print());
} }
this.logger.log(`Cleaned up ${result} derivatives`); this.logger.log(`Cleaned up ${result} derivatives`);
@ -71,7 +71,7 @@ export class ImageManagerModule implements OnModuleInit, OnModuleDestroy {
const cleanedUp = await this.imageDB.cleanupExpired(); const cleanedUp = await this.imageDB.cleanupExpired();
if (HasFailed(cleanedUp)) { if (HasFailed(cleanedUp)) {
this.logger.warn(`Failed to cleanup expired images`); this.logger.warn(cleanedUp.print());
} }
this.logger.log(`Cleaned up ${cleanedUp} expired images`); this.logger.log(`Cleaned up ${cleanedUp} expired images`);

View file

@ -57,6 +57,11 @@ export class ImageManagerService {
userid: string | undefined, userid: string | undefined,
options: Partial<Pick<EImageBackend, 'file_name' | 'expires_at'>>, options: Partial<Pick<EImageBackend, 'file_name' | 'expires_at'>>,
): AsyncFailable<EImageBackend> { ): AsyncFailable<EImageBackend> {
if (options.expires_at !== undefined && options.expires_at !== null) {
if (options.expires_at < new Date()) {
return Fail(FT.UsrValidation, 'Expiration date must be in the future');
}
}
return await this.imagesService.update(id, userid, options); return await this.imagesService.update(id, userid, options);
} }

View file

@ -12,6 +12,11 @@
<mat-card-title>{{ image.file_name | truncate }}</mat-card-title> <mat-card-title>{{ image.file_name | truncate }}</mat-card-title>
<mat-card-subtitle> <mat-card-subtitle>
Uploaded {{ image.created | amTimeAgo }} Uploaded {{ image.created | amTimeAgo }}
{{
image.expires_at === null
? ''
: '| Expires ' + (image.expires_at | amTimeAgo)
}}
</mat-card-subtitle> </mat-card-subtitle>
</mat-card-header> </mat-card-header>
<picsur-img <picsur-img

View file

@ -5,6 +5,7 @@ import { ImageFileType } from 'picsur-shared/dist/dto/mimes.dto';
import { EImage } from 'picsur-shared/dist/entities/image.entity'; import { EImage } from 'picsur-shared/dist/entities/image.entity';
import { HasFailed } from 'picsur-shared/dist/types/failable'; import { HasFailed } from 'picsur-shared/dist/types/failable';
import { ImageService } from 'src/app/services/api/image.service'; import { ImageService } from 'src/app/services/api/image.service';
import { UserService } from 'src/app/services/api/user.service';
import { Logger } from 'src/app/services/logger/logger.service'; import { Logger } from 'src/app/services/logger/logger.service';
import { BootstrapService, BSScreenSize } from 'src/app/util/bootstrap.service'; import { BootstrapService, BSScreenSize } from 'src/app/util/bootstrap.service';
import { DialogService } from 'src/app/util/dialog-manager/dialog.service'; import { DialogService } from 'src/app/util/dialog-manager/dialog.service';
@ -27,6 +28,7 @@ export class ImagesComponent implements OnInit {
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly router: Router, private readonly router: Router,
private readonly bootstrapService: BootstrapService, private readonly bootstrapService: BootstrapService,
private readonly userService: UserService,
private readonly imageService: ImageService, private readonly imageService: ImageService,
private readonly errorService: ErrorService, private readonly errorService: ErrorService,
private readonly dialogService: DialogService, private readonly dialogService: DialogService,
@ -44,14 +46,7 @@ export class ImagesComponent implements OnInit {
this.page = thispage; this.page = thispage;
this.subscribeMobile(); this.subscribeMobile();
this.subscribeUser();
const list = await this.imageService.ListMyImages(24, this.page - 1);
if (HasFailed(list)) {
return this.logger.error(list.getReason());
}
this.pages = list.pages;
this.images = list.results;
} }
@AutoUnsubscribe() @AutoUnsubscribe()
@ -67,6 +62,19 @@ export class ImagesComponent implements OnInit {
}); });
} }
@AutoUnsubscribe()
private subscribeUser() {
return this.userService.live.subscribe(async () => {
const list = await this.imageService.ListMyImages(24, this.page - 1);
if (HasFailed(list)) {
return this.logger.error(list.getReason());
}
this.pages = list.pages;
this.images = list.results;
});
}
getThumbnailUrl(image: EImage) { getThumbnailUrl(image: EImage) {
return ( return (
this.imageService.GetImageURL(image.id, ImageFileType.QOI) + this.imageService.GetImageURL(image.id, ImageFileType.QOI) +

View file

@ -91,6 +91,6 @@
</div> </div>
</div> </div>
<div class="dialog-buttons"> <div class="dialog-buttons">
<button mat-stroked-button (click)="close()">Close</button> <button mat-flat-button (click)="close()">CLOSE</button>
</div> </div>
</div> </div>

View file

@ -0,0 +1,27 @@
<div class="dialog-text">
<div class="row">
<div class="col-12">
<h1>Edit Image Properties</h1>
</div>
<div class="col-12">
<mat-form-field appearance="outline" color="accent">
<mat-label>Title</mat-label>
<input matInput type="text" [(ngModel)]="image.file_name" />
</mat-form-field>
</div>
<div class="col-12">
<mat-form-field>
<mat-label>Expires After</mat-label>
<mat-select [(value)]="expiresAfter" >
<mat-option *ngFor="let option of ExpireOptions" [value]="option[1]">
{{ option[0] }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="dialog-buttons">
<button mat-flat-button (click)="close()">CANCEL</button>
<button mat-raised-button color="accent" (click)="save()">SAVE</button>
</div>
</div>

View file

@ -0,0 +1,3 @@
mat-form-field {
width: 100%;
}

View file

@ -0,0 +1,76 @@
import { Component, Inject, OnInit } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { EImage } from 'picsur-shared/dist/entities/image.entity';
import { HasFailed } from 'picsur-shared/dist/types';
import { ImageService } from 'src/app/services/api/image.service';
import { Logger } from 'src/app/services/logger/logger.service';
import { ErrorService } from 'src/app/util/error-manager/error.service';
export interface EditDialogData {
image: EImage;
}
@Component({
selector: 'edit-dialog',
templateUrl: './edit-dialog.component.html',
styleUrls: ['./edit-dialog.component.scss'],
})
export class EditDialogComponent implements OnInit {
private readonly logger = new Logger(EditDialogComponent.name);
public readonly ExpireOptions: Array<[string, null | number]> = [
['Never', 0],
['5 Minutes', 5 * 60],
['10 Minutes', 10 * 60],
['30 Minutes', 15 * 60],
['1 Hour', 60 * 60],
['6 Hours', 2 * 60 * 60],
['12 Hours', 12 * 60 * 60],
['1 Day', 24 * 60 * 60],
['1 Week', 7 * 24 * 60 * 60],
['1 Month', 30 * 24 * 60 * 60],
];
public expiresAfter: number = 0;
public image: EImage;
constructor(
public readonly dialogRef: MatDialogRef<EditDialogComponent>,
private readonly imageService: ImageService,
private readonly errorService: ErrorService,
@Inject(MAT_DIALOG_DATA) data: EditDialogData,
) {
if (!data.image) {
throw new Error('imageID is required');
}
this.image = data.image;
}
async ngOnInit() {
console.log(this.image);
}
close() {
this.dialogRef.close(null);
}
async save() {
const result = await this.imageService.UpdateImage(this.image.id, {
file_name: this.image.file_name,
expires_at:
this.expiresAfter === 0
? null
: new Date(Date.now() + this.expiresAfter * 1000),
});
if (HasFailed(result)) {
this.errorService.showFailure(result, this.logger);
return this.close();
}
this.errorService.success('Image successfully updated');
this.dialogRef.close(result);
}
}

View file

@ -8,7 +8,14 @@
</div> </div>
<div class="col-12" *ngIf="image !== null"> <div class="col-12" *ngIf="image !== null">
<h3>Uploaded {{ image.created | amTimeAgo }}</h3> <h3>
Uploaded {{ image.created | amTimeAgo }}
{{
image.expires_at === null
? ''
: '| Expires ' + (image.expires_at | amTimeAgo)
}}
</h3>
</div> </div>
<div class="col-12 py-3"> <div class="col-12 py-3">
@ -66,7 +73,15 @@
<mat-icon fontSet="material-icons-outlined"> share </mat-icon> <mat-icon fontSet="material-icons-outlined"> share </mat-icon>
</button> </button>
<button <button
*ngIf="canDelete" *ngIf="canManage"
mat-mini-fab
matTooltip="Edit image"
(click)="editImage()"
>
<mat-icon fontSet="material-icons-outlined"> edit </mat-icon>
</button>
<button
*ngIf="canManage"
mat-mini-fab mat-mini-fab
matTooltip="Delete image" matTooltip="Delete image"
(click)="deleteImage()" (click)="deleteImage()"

View file

@ -28,6 +28,10 @@ import {
CustomizeDialogComponent, CustomizeDialogComponent,
CustomizeDialogData, CustomizeDialogData,
} from './customize-dialog/customize-dialog.component'; } from './customize-dialog/customize-dialog.component';
import {
EditDialogComponent,
EditDialogData,
} from './edit-dialog/edit-dialog.component';
@Component({ @Component({
templateUrl: './view.component.html', templateUrl: './view.component.html',
@ -70,7 +74,7 @@ export class ViewComponent implements OnInit {
public image: EImage | null = null; public image: EImage | null = null;
public imageUser: EUser | null = null; public imageUser: EUser | null = null;
public canDelete: boolean = false; public canManage: boolean = false;
async ngOnInit() { async ngOnInit() {
this.subscribePermissions(); this.subscribePermissions();
@ -194,6 +198,26 @@ export class ViewComponent implements OnInit {
); );
} }
async editImage() {
if (this.image === null) return;
const options: EditDialogData = {
image: { ...this.image },
};
const res: EImage | null = await this.dialogService.showCustomDialog(
EditDialogComponent,
options,
{
dismissable: false,
},
);
if (res !== null) {
this.image = res;
}
}
@AutoUnsubscribe() @AutoUnsubscribe()
private subscribePermissions() { private subscribePermissions() {
return this.permissionService.live.subscribe( return this.permissionService.live.subscribe(
@ -204,21 +228,21 @@ export class ViewComponent implements OnInit {
private updatePermissions() { private updatePermissions() {
const permissions = this.permissionService.snapshot; const permissions = this.permissionService.snapshot;
if (permissions.includes(Permission.ImageAdmin)) { if (permissions.includes(Permission.ImageAdmin)) {
this.canDelete = true; this.canManage = true;
return; return;
} }
if (this.imageUser === null) return; if (this.imageUser === null) return;
if ( if (
permissions.includes(Permission.ImageUpload) && permissions.includes(Permission.ImageManage) &&
this.imageUser.id === this.userService.snapshot?.id this.imageUser.id === this.userService.snapshot?.id
) { ) {
this.canDelete = true; this.canManage = true;
return; return;
} }
this.canDelete = false; this.canManage = false;
} }
private updateFormatOptions() { private updateFormatOptions() {

View file

@ -17,10 +17,12 @@ import { PipesModule } from 'src/app/pipes/pipes.module';
import { DownloadManagerModule } from 'src/app/util/download-manager/dialog-manager.module'; import { DownloadManagerModule } from 'src/app/util/download-manager/dialog-manager.module';
import { ErrorManagerModule } from 'src/app/util/error-manager/error-manager.module'; import { ErrorManagerModule } from 'src/app/util/error-manager/error-manager.module';
import { CustomizeDialogComponent } from './customize-dialog/customize-dialog.component'; import { CustomizeDialogComponent } from './customize-dialog/customize-dialog.component';
import { EditDialogComponent } from './edit-dialog/edit-dialog.component';
import { ViewComponent } from './view.component'; import { ViewComponent } from './view.component';
import { ViewRoutingModule } from './view.routing.module'; import { ViewRoutingModule } from './view.routing.module';
@NgModule({ @NgModule({
declarations: [ViewComponent, CustomizeDialogComponent], declarations: [ViewComponent, CustomizeDialogComponent, EditDialogComponent],
imports: [ imports: [
CommonModule, CommonModule,
ErrorManagerModule, ErrorManagerModule,

View file

@ -4,6 +4,8 @@ import {
ImageDeleteResponse, ImageDeleteResponse,
ImageListRequest, ImageListRequest,
ImageListResponse, ImageListResponse,
ImageUpdateRequest,
ImageUpdateResponse,
ImageUploadResponse, ImageUploadResponse,
} from 'picsur-shared/dist/dto/api/image-manage.dto'; } from 'picsur-shared/dist/dto/api/image-manage.dto';
import { import {
@ -79,6 +81,21 @@ export class ImageService {
return await this.ListAllImages(count, page, userID); return await this.ListAllImages(count, page, userID);
} }
public async UpdateImage(
id: string,
settings: Partial<Pick<EImage, 'file_name' | 'expires_at'>>,
): AsyncFailable<EImage> {
return await this.api.post(
ImageUpdateRequest,
ImageUpdateResponse,
'/api/image/update',
{
id,
...settings,
},
);
}
public async DeleteImages( public async DeleteImages(
images: string[], images: string[],
): AsyncFailable<ImageDeleteResponse> { ): AsyncFailable<ImageDeleteResponse> {

View file

@ -1,11 +1,17 @@
import { z } from 'zod'; import { z } from 'zod';
import { IsEntityID } from '../validators/entity-id.validator'; import { IsEntityID } from '../validators/entity-id.validator';
import { IsPosInt } from '../validators/positive-int.validator';
const MONTH_IN_SECONDS = 60 * 60 * 24 * 30;
const FIVE_MIN_IN_SECONDS = 60 * 5;
export const EImageSchema = z.object({ export const EImageSchema = z.object({
id: IsEntityID(), id: IsEntityID(),
user_id: IsEntityID(), user_id: IsEntityID(),
created: z.preprocess((data: any) => new Date(data), z.date()), created: z.preprocess((data: any) => new Date(data), z.date()),
file_name: z.string(), file_name: z.string(),
expires_at: z.preprocess((data: any) => new Date(data), z.date()).nullable(), expires_at: z
.preprocess((data: any) => new Date(data), z.date())
.nullable(),
}); });
export type EImage = z.infer<typeof EImageSchema>; export type EImage = z.infer<typeof EImageSchema>;