better shared api calls

This commit is contained in:
rubikscraft 2022-02-25 12:22:00 +01:00
parent 825b2856bb
commit 626e228991
No known key found for this signature in database
GPG key ID: 1463EBE9200A5CD4
37 changed files with 407 additions and 131 deletions

11
.vscode/tasks.json vendored
View file

@ -3,7 +3,12 @@
"tasks": [ "tasks": [
{ {
"label": "Start full", "label": "Start full",
"dependsOn": ["Start backend", "Start frontend", "Start postgres"], "dependsOn": [
"Start backend",
"Start frontend",
"Start postgres",
"Start shared"
],
"dependsOrder": "parallel", "dependsOrder": "parallel",
"isBackground": true, "isBackground": true,
"group": "build" "group": "build"
@ -24,7 +29,6 @@
"options": { "options": {
"cwd": "./backend" "cwd": "./backend"
}, },
"dependsOn": ["Start shared"],
"group": "build" "group": "build"
}, },
{ {
@ -34,13 +38,12 @@
"options": { "options": {
"cwd": "./frontend" "cwd": "./frontend"
}, },
"dependsOn": ["Start shared"],
"group": "build" "group": "build"
}, },
{ {
"type": "shell", "type": "shell",
"label": "Start postgres", "label": "Start postgres",
"command": "podman-compose up", "command": "podman-compose stop; podman-compose up",
"options": { "options": {
"cwd": "./support" "cwd": "./support"
}, },

View file

@ -0,0 +1,8 @@
import { IsDefined, ValidateNested } from 'class-validator';
import { MultiPartFileDto } from './multipart.dto';
export class ImageUploadDto {
@IsDefined()
@ValidateNested()
image: MultiPartFileDto;
}

View file

@ -10,7 +10,7 @@ import { FastifyRequest } from 'fastify';
import { Multipart, MultipartFields, MultipartFile } from 'fastify-multipart'; import { Multipart, MultipartFields, MultipartFile } from 'fastify-multipart';
import { Newable } from 'imagur-shared/dist/types'; import { Newable } from 'imagur-shared/dist/types';
import Config from '../env'; import Config from '../env';
import { MultiPartFieldDto, MultiPartFileDto } from './multipart.dto'; import { MultiPartFieldDto, MultiPartFileDto } from '../backenddto/multipart.dto';
const logger = new Logger('MultiPart'); const logger = new Logger('MultiPart');
export interface MPFile { export interface MPFile {
@ -47,10 +47,8 @@ export const PostFile = createParamDecorator(
}, },
); );
export class MultiPartDto {}
export const MultiPart = createParamDecorator( export const MultiPart = createParamDecorator(
async <T extends MultiPartDto>(data: Newable<T>, ctx: ExecutionContext) => { async <T extends Object>(data: Newable<T>, ctx: ExecutionContext) => {
const req: FastifyRequest = ctx.switchToHttp().getRequest(); const req: FastifyRequest = ctx.switchToHttp().getRequest();
const dtoClass = new data(); const dtoClass = new data();

View file

@ -6,6 +6,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { FastifyReply } from 'fastify'; import { FastifyReply } from 'fastify';
import { ApiErrorResponse, ApiResponse } from 'imagur-shared/dist/dto/api.dto';
@Catch(HttpException) @Catch(HttpException)
export class MainExceptionFilter implements ExceptionFilter { export class MainExceptionFilter implements ExceptionFilter {
@ -15,14 +16,16 @@ export class MainExceptionFilter implements ExceptionFilter {
const request = ctx.getRequest<FastifyRequest>(); const request = ctx.getRequest<FastifyRequest>();
const status = exception.getStatus(); const status = exception.getStatus();
response.status(status).send({ const toSend: ApiErrorResponse = {
success: status < 400, success: false,
statusCode: status, statusCode: status,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
data: { data: {
message: exception.message, message: exception.message,
}, },
}); };
response.status(status).send(toSend);
} }
} }

View file

@ -4,7 +4,7 @@ import {
ExecutionContext, ExecutionContext,
CallHandler, CallHandler,
} from '@nestjs/common'; } from '@nestjs/common';
import { FastifyReply } from 'fastify'; import { ApiResponse } from 'imagur-shared/dist/dto/api.dto';
import { Observable, map } from 'rxjs'; import { Observable, map } from 'rxjs';
@Injectable() @Injectable()
@ -16,13 +16,15 @@ export class SuccessInterceptor<T> implements NestInterceptor {
return data; return data;
} else if (typeof data === 'object') { } else if (typeof data === 'object') {
const status = context.switchToHttp().getResponse().statusCode; const status = context.switchToHttp().getResponse().statusCode;
return { const response: ApiResponse<any> = {
success: status < 400, success: true,
statusCode: status, statusCode: status,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
data, data,
}; };
return response;
} else { } else {
return data; return data;
} }

View file

@ -1,9 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isHash } from 'class-validator';
import { fileTypeFromBuffer, FileTypeResult } from 'file-type'; import { fileTypeFromBuffer, FileTypeResult } from 'file-type';
import { AsyncFailable, Fail, HasFailed } from 'imagur-shared/dist/types'; import { AsyncFailable, Fail, HasFailed } from 'imagur-shared/dist/types';
import { ImageEntity } from '../../collections/imagedb/image.entity'; import { ImageEntity } from '../../collections/imagedb/image.entity';
import { ImageDBService } from '../../collections/imagedb/imagedb.service'; import { ImageDBService } from '../../collections/imagedb/imagedb.service';
import { MimesService, FullMime } from '../../collections/imagedb/mimes.service'; import {
MimesService,
FullMime,
} from '../../collections/imagedb/mimes.service';
@Injectable() @Injectable()
export class ImageManagerService { export class ImageManagerService {
@ -13,7 +17,7 @@ export class ImageManagerService {
) {} ) {}
public async retrieve(hash: string): AsyncFailable<ImageEntity> { public async retrieve(hash: string): AsyncFailable<ImageEntity> {
if (!this.validateHash(hash)) return Fail('Invalid hash'); if (!isHash(hash, 'sha256')) return Fail('Invalid hash');
return await this.imagesService.findOne(hash); return await this.imagesService.findOne(hash);
} }
@ -44,8 +48,4 @@ export class ImageManagerService {
); );
return fullMime; return fullMime;
} }
public validateHash(hash: string): boolean {
return /^[a-f0-9]{64}$/.test(hash);
}
} }

View file

@ -6,7 +6,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { plainToClass } from 'class-transformer'; import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { User } from '../../../collections/userdb/user.dto'; import { User } from 'imagur-shared/dist/dto/user.dto';
@Injectable() @Injectable()
export class AdminGuard implements CanActivate { export class AdminGuard implements CanActivate {

View file

@ -10,16 +10,12 @@ import {
InternalServerErrorException, InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
import { LocalAuthGuard } from './local-auth.guard'; import { LocalAuthGuard } from './local-auth.guard';
import {
RegisterRequestDto,
LoginResponseDto,
DeleteRequestDto,
} from './auth.dto';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt.guard'; import { JwtAuthGuard } from './jwt.guard';
import { AdminGuard } from './admin.guard'; import { AdminGuard } from './admin.guard';
import { HasFailed } from 'imagur-shared/dist/types'; import { HasFailed } from 'imagur-shared/dist/types';
import AuthFasityRequest from './authrequest'; import AuthFasityRequest from './authrequest';
import { AuthDeleteRequest, AuthLoginResponse, AuthRegisterRequest } from 'imagur-shared/dist/dto/auth.dto';
@Controller('api/auth') @Controller('api/auth')
export class AuthController { export class AuthController {
@ -28,7 +24,7 @@ export class AuthController {
@UseGuards(LocalAuthGuard) @UseGuards(LocalAuthGuard)
@Post('login') @Post('login')
async login(@Request() req: AuthFasityRequest) { async login(@Request() req: AuthFasityRequest) {
const response: LoginResponseDto = { const response: AuthLoginResponse = {
access_token: await this.authService.createToken(req.user), access_token: await this.authService.createToken(req.user),
}; };
@ -39,7 +35,7 @@ export class AuthController {
@Post('create') @Post('create')
async register( async register(
@Request() req: AuthFasityRequest, @Request() req: AuthFasityRequest,
@Body() register: RegisterRequestDto, @Body() register: AuthRegisterRequest,
) { ) {
const user = await this.authService.createUser( const user = await this.authService.createUser(
register.username, register.username,
@ -59,7 +55,7 @@ export class AuthController {
@Post('delete') @Post('delete')
async delete( async delete(
@Request() req: AuthFasityRequest, @Request() req: AuthFasityRequest,
@Body() deleteData: DeleteRequestDto, @Body() deleteData: AuthDeleteRequest,
) { ) {
const user = await this.authService.deleteUser(deleteData.username); const user = await this.authService.deleteUser(deleteData.username);
if (HasFailed(user)) throw new NotFoundException('User does not exist'); if (HasFailed(user)) throw new NotFoundException('User does not exist');

View file

@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { JwtDataDto } from 'imagur-shared/dist/dto/auth.dto';
import { User } from 'imagur-shared/dist/dto/user.dto';
import { AsyncFailable, HasFailed, Fail } from 'imagur-shared/dist/types'; import { AsyncFailable, HasFailed, Fail } from 'imagur-shared/dist/types';
import { User } from '../../../collections/userdb/user.dto';
import { UserEntity } from '../../../collections/userdb/user.entity'; import { UserEntity } from '../../../collections/userdb/user.entity';
import { UsersService } from '../../../collections/userdb/userdb.service'; import { UsersService } from '../../../collections/userdb/userdb.service';
import { JwtDataDto } from './auth.dto';
@Injectable() @Injectable()
export class AuthService { export class AuthService {

View file

@ -1,5 +1,5 @@
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { User } from '../../../collections/userdb/user.dto'; import { User } from 'imagur-shared/dist/dto/user.dto';
export default interface AuthFasityRequest extends FastifyRequest { export default interface AuthFasityRequest extends FastifyRequest {
user: User; user: User;

View file

@ -2,10 +2,10 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { JwtDataDto } from './auth.dto';
import { plainToClass } from 'class-transformer'; import { plainToClass } from 'class-transformer';
import { User } from '../../../collections/userdb/user.dto';
import Config from '../../../env'; import Config from '../../../env';
import { JwtDataDto } from 'imagur-shared/dist/dto/auth.dto';
import { User } from 'imagur-shared/dist/dto/user.dto';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {

View file

@ -2,8 +2,8 @@ import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { User } from '../../../collections/userdb/user.dto';
import { AsyncFailable, HasFailed } from 'imagur-shared/dist/types'; import { AsyncFailable, HasFailed } from 'imagur-shared/dist/types';
import { User } from 'imagur-shared/dist/dto/user.dto';
@Injectable() @Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') { export class LocalStrategy extends PassportStrategy(Strategy, 'local') {

View file

@ -13,7 +13,8 @@ import { FastifyReply, FastifyRequest } from 'fastify';
import { HasFailed } from 'imagur-shared/dist/types'; import { HasFailed } from 'imagur-shared/dist/types';
import { MultiPart } from '../../decorators/multipart.decorator'; import { MultiPart } from '../../decorators/multipart.decorator';
import { ImageManagerService } from '../../managers/imagemanager/imagemanager.service'; import { ImageManagerService } from '../../managers/imagemanager/imagemanager.service';
import { ImageUploadDto } from './imageroute.dto'; import { ImageUploadDto } from '../../backenddto/imageroute.dto';
import { isHash } from 'class-validator';
@Controller('i') @Controller('i')
export class ImageController { export class ImageController {
constructor(private readonly imagesService: ImageManagerService) {} constructor(private readonly imagesService: ImageManagerService) {}
@ -23,8 +24,7 @@ export class ImageController {
@Res({ passthrough: true }) res: FastifyReply, @Res({ passthrough: true }) res: FastifyReply,
@Param('hash') hash: string, @Param('hash') hash: string,
) { ) {
if (!this.imagesService.validateHash(hash)) if (!isHash(hash, 'sha256')) throw new BadRequestException('Invalid hash');
throw new BadRequestException('Invalid hash');
const image = await this.imagesService.retrieve(hash); const image = await this.imagesService.retrieve(hash);
if (HasFailed(image)) if (HasFailed(image))

View file

@ -1,9 +0,0 @@
import { IsDefined, ValidateNested } from 'class-validator';
import { MultiPartDto } from '../../decorators/multipart.decorator';
import { MultiPartFileDto } from '../../decorators/multipart.dto';
export class ImageUploadDto extends MultiPartDto {
@IsDefined()
@ValidateNested()
image: MultiPartFileDto;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
branding/logo/imagur.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

61
branding/logo/imagur.svg Normal file
View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="1024"
height="1024"
viewBox="0 0 270.93333 270.93333"
version="1.1"
id="svg5"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="imagur.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="px"
showgrid="false"
units="px"
inkscape:zoom="0.38262135"
inkscape:cx="799.74627"
inkscape:cy="656.00103"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="1280"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#121212;fill-opacity:1;stroke:none;stroke-width:30.588;stroke-linecap:round;stroke-linejoin:round"
id="rect1068"
width="270.93332"
height="270.93332"
x="0"
y="-3.5527137e-15" />
<circle
style="fill:#43a047;fill-opacity:1;stroke:none;stroke-width:19.7396;stroke-linecap:round;stroke-linejoin:round"
id="path1293-6"
cx="135.46666"
cy="212.93398"
r="27.119791" />
<path
style="fill:none;stroke:#43a047;stroke-width:54.1131;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 135.40342,57.936334 V 136.24119"
id="path5020"
sodipodi:nodetypes="cc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -13,12 +13,14 @@
"@emotion/styled": "^11.8.1", "@emotion/styled": "^11.8.1",
"@mui/icons-material": "^5.4.2", "@mui/icons-material": "^5.4.2",
"@mui/material": "^5.4.3", "@mui/material": "^5.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"imagur-shared": "*",
"notistack": "^2.0.3", "notistack": "^2.0.3",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-dropzone": "^12.0.4", "react-dropzone": "^12.0.4",
"react-router-dom": "^6.2.1", "react-router-dom": "^6.2.1"
"imagur-shared": "*"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.17.5", "@babel/core": "^7.17.5",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

1
frontend/public/image/logo Symbolic link
View file

@ -0,0 +1 @@
/home/rubikscraft/Documents/Projects/Imagur/Imagur-Monorepo/branding/logo

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="/image/logo/imagur.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/normalize.css" /> <link rel="stylesheet" href="/css/normalize.css" />

View file

@ -1,14 +0,0 @@
export interface ApiResponse<T> {
success: boolean;
statusCode: number;
timestamp: string;
data: T;
}
export type ApiErrorResponse = ApiResponse<{
message: string;
}>;
export type ApiUploadResponse = ApiResponse<{
hash: string;
}>;

133
frontend/src/api/api.ts Normal file
View file

@ -0,0 +1,133 @@
import { AsyncFailable, Fail, HasFailed } from 'imagur-shared/dist/types';
import {
ApiResponse,
ApiSuccessResponse,
} from 'imagur-shared/dist/dto/api.dto';
import { validate } from 'class-validator';
import { ClassConstructor, plainToClass } from 'class-transformer';
export abstract class MultiPartRequest {
public abstract createFormData(): FormData;
}
class ImagurApiImplementation {
private async fetch(
url: RequestInfo,
options: RequestInit,
): AsyncFailable<Response> {
try {
return await window.fetch(url, options);
} catch (e: any) {
console.warn(e);
return Fail('Something went wrong');
}
}
private async fetchJsonAs<T>(
url: RequestInfo,
options: RequestInit,
): AsyncFailable<T> {
const response = await this.fetch(url, options);
if (HasFailed(response)) {
return response;
}
try {
return await response.json();
} catch (e) {
console.warn(e);
return Fail('Something went wrong');
}
}
private async fetchBuffer(
url: RequestInfo,
options: RequestInit,
): AsyncFailable<ArrayBuffer> {
const response = await this.fetch(url, options);
if (HasFailed(response)) return response;
try {
return await response.arrayBuffer();
} catch (e) {
console.warn(e);
return Fail('Something went wrong');
}
}
private async fetchSafeJson<T extends Object>(
type: ClassConstructor<T>,
url: RequestInfo,
options: RequestInit,
): AsyncFailable<T> {
let result = await this.fetchJsonAs<ApiResponse<T>>(url, options);
if (HasFailed(result)) return result;
if (result.success === false) return Fail(result.data.message);
const resultClass = plainToClass<
ApiSuccessResponse<T>,
ApiSuccessResponse<T>
>(ApiSuccessResponse, result);
const resultErrors = await validate(resultClass);
if (resultErrors.length > 0) {
console.warn('result', resultErrors);
return Fail('Something went wrong');
}
const dataClass = plainToClass(type, result.data);
const dataErrors = await validate(dataClass);
if (dataErrors.length > 0) {
console.warn('data', dataErrors);
return Fail('Something went wrong');
}
return result.data;
}
public async get<T extends Object>(
type: ClassConstructor<T>,
url: string,
): AsyncFailable<T> {
return this.fetchSafeJson(type, url, { method: 'GET' });
}
public async post<T extends Object, W extends Object>(
sendType: ClassConstructor<T>,
receiveType: ClassConstructor<W>,
url: string,
data: object,
): AsyncFailable<W> {
const sendClass = plainToClass(sendType, data);
const errors = await validate(sendClass);
if (errors.length > 0) {
console.warn(errors);
return Fail('Something went wrong');
}
return this.fetchSafeJson(receiveType, url, {
method: 'POST',
body: JSON.stringify(sendClass),
});
}
public async postForm<T extends Object>(
receiveType: ClassConstructor<T>,
url: string,
data: MultiPartRequest,
): AsyncFailable<T> {
return this.fetchSafeJson(receiveType, url, {
method: 'POST',
body: data.createFormData(),
});
}
}
export default abstract class ImagurApi {
private static readonly instance = new ImagurApiImplementation();
protected get api() {
return ImagurApi.instance;
}
protected constructor() {}
}

View file

@ -1,27 +1,47 @@
import { AsyncFailable, Fail } from 'imagur-shared/dist/types' import { AsyncFailable, HasFailed } from 'imagur-shared/dist/types';
import { ImageUploadResponse } from 'imagur-shared/dist/dto/images.dto';
import { ImageUploadRequest } from '../frontenddto/imageroute.dto';
import ImagurApi from './api';
export function GetImageURL(image: string): string { export interface ImageLinks {
source: string;
markdown: string;
html: string;
rst: string;
bbcode: string;
}
export default class ImagesApi extends ImagurApi {
public static readonly I = new ImagesApi();
protected constructor() {
super();
}
public async UploadImage(image: File): AsyncFailable<string> {
const result = await this.api.postForm(
ImageUploadResponse,
'/i',
new ImageUploadRequest(image),
);
if (HasFailed(result)) return result;
return result.hash;
}
public static GetImageURL(image: string): string {
const baseURL = window.location.protocol + '//' + window.location.host; const baseURL = window.location.protocol + '//' + window.location.host;
return `${baseURL}/i/${image}`; return `${baseURL}/i/${image}`;
} }
export function ValidateImageHash(hash: string): boolean { public static CreateImageLinks(imageURL: string) {
return /^[a-f0-9]{64}$/.test(hash); return {
source: imageURL,
markdown: `![image](${imageURL})`,
html: `<img src="${imageURL}" alt="image">`,
rst: `.. image:: ${imageURL}`,
bbcode: `[img]${imageURL}[/img]`,
};
} }
export async function UploadImage(image: File): AsyncFailable<string> {
const formData = new FormData();
formData.append('image', image);
let result = await fetch('/i', {
method: 'POST',
body: formData,
}).then((res) => res.json());
console.log(result);
if (!result.hash) return Fail(result.error);
return result.hash;
} }

View file

@ -1,31 +0,0 @@
export interface ImageLinks {
source: string;
markdown: string;
html: string;
rst: string;
bbcode: string;
}
export function CreateImageLinks(imageURL: string) {
return {
source: imageURL,
markdown: `![image](${imageURL})`,
html: `<img src="${imageURL}" alt="image">`,
rst: `.. image:: ${imageURL}`,
bbcode: `[img]${imageURL}[/img]`,
};
}
export function Debounce(fn: Function, ms: number) {
let timer: number | undefined;
return () => {
clearTimeout(timer);
timer = setTimeout((_) => {
timer = undefined;
fn();
}, ms);
};
}

View file

@ -0,0 +1,11 @@
import { MultiPartRequest } from '../api/api';
export class ImageUploadRequest implements MultiPartRequest {
constructor(private image: File) {}
public createFormData(): FormData {
const data = new FormData();
data.append('image', this.image);
return data;
}
}

View file

@ -0,0 +1,13 @@
export default function Debounce(fn: Function, ms: number) {
let timer: number | undefined;
return () => {
clearTimeout(timer);
timer = setTimeout((_) => {
timer = undefined;
fn();
}, ms);
};
}

View file

@ -2,8 +2,8 @@ import Centered from '../../components/centered/centered';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { UploadImage } from '../../api/images';
import { HasFailed } from 'imagur-shared/dist/types'; import { HasFailed } from 'imagur-shared/dist/types';
import ImagesApi from '../../api/images';
export interface ProcessingViewMetadata { export interface ProcessingViewMetadata {
imageFile: File; imageFile: File;
@ -16,7 +16,7 @@ function ProcessingView(props: any) {
async function onRendered() { async function onRendered() {
if (!state) navigate('/'); if (!state) navigate('/');
const hash = await UploadImage(state.imageFile); const hash = await ImagesApi.I.UploadImage(state.imageFile);
if (HasFailed(hash)) navigate('/'); // TODO: handle error if (HasFailed(hash)) navigate('/'); // TODO: handle error
navigate('/view/' + hash); navigate('/view/' + hash);

View file

@ -1,13 +1,14 @@
import { Button, Grid, IconButton, TextField } from '@mui/material'; import { Button, Grid, IconButton, TextField } from '@mui/material';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { GetImageURL, ValidateImageHash } from '../../api/images';
import { CreateImageLinks, Debounce } from '../../api/util';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import Centered from '../../components/centered/centered'; import Centered from '../../components/centered/centered';
import './view.css'; import './view.css';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import Debounce from '../../lib/debounce';
import { isHash } from 'class-validator';
import ImagesApi from '../../api/images';
// Stupid names go brrr // Stupid names go brrr
export default function ViewView() { export default function ViewView() {
@ -30,7 +31,7 @@ export default function ViewView() {
}; };
useEffect(() => { useEffect(() => {
if (!ValidateImageHash(hash)) navigate('/'); if (!isHash(hash, 'sha256')) navigate('/');
resizeImage(); resizeImage();
@ -39,8 +40,8 @@ export default function ViewView() {
return () => window.removeEventListener('resize', resizeImageDebounced); return () => window.removeEventListener('resize', resizeImageDebounced);
}); });
const imageURL = GetImageURL(hash); const imageURL = ImagesApi.GetImageURL(hash);
const imageLinks = CreateImageLinks(imageURL); const imageLinks = ImagesApi.CreateImageLinks(imageURL);
function createCopyField(label: string, value: string) { function createCopyField(label: string, value: string) {
const copy = () => { const copy = () => {

View file

@ -6,9 +6,10 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"repository": "https://github.com/rubikscraft/Imagur", "repository": "https://github.com/rubikscraft/Imagur",
"author": "Rubikscraft <contact@rubikscraft.nl>", "author": "Rubikscraft <contact@rubikscraft.nl>",
"type": "commonjs", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"dependencies": { "dependencies": {
"class-validator": "^0.13.2",
"tsc-watch": "^4.6.0" "tsc-watch": "^4.6.0"
}, },
"devDependencies": { "devDependencies": {

45
shared/src/dto/api.dto.ts Normal file
View file

@ -0,0 +1,45 @@
import {
Equals,
IsBoolean,
IsDefined,
IsInt,
IsNotEmpty,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
class BaseApiResponse<T extends Object, W extends boolean> {
@IsBoolean()
@IsDefined()
success: W;
@IsInt()
@Min(0)
@Max(1000)
@IsDefined()
statusCode: number;
@IsString()
@IsNotEmpty()
timestamp: string;
@ValidateNested()
@IsDefined()
data: T;
}
export class ApiSuccessResponse<T extends Object> extends BaseApiResponse<
T,
true
> {}
export class ApiErrorData {
@IsString()
@IsNotEmpty()
message: string;
}
export class ApiErrorResponse extends BaseApiResponse<ApiErrorData, false> {}
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

View file

@ -4,17 +4,30 @@ import {
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
IsInt,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { User } from '../../../collections/userdb/user.dto'; import { User } from './user.dto';
export class LoginResponseDto { // Api
export class AuthLoginRequest {
@IsNotEmpty()
@IsString()
username: string;
@IsNotEmpty()
@IsString()
password: string;
}
export class AuthLoginResponse {
@IsString() @IsString()
@IsDefined() @IsDefined()
access_token: string; access_token: string;
} }
export class RegisterRequestDto { export class AuthRegisterRequest {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
username: string; username: string;
@ -28,14 +41,26 @@ export class RegisterRequestDto {
isAdmin?: boolean; isAdmin?: boolean;
} }
export class DeleteRequestDto { export class AuthDeleteRequest {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
username: string; username: string;
} }
export class AuthDeleteResponse extends User {}
// Extra
export class JwtDataDto { export class JwtDataDto {
@ValidateNested() @ValidateNested()
@IsDefined() @IsDefined()
user: User; user: User;
@IsOptional()
@IsInt()
iat?: number;
@IsOptional()
@IsInt()
exp?: number;
} }

View file

@ -0,0 +1,7 @@
import { IsHash, IsString } from 'class-validator';
export class ImageUploadResponse {
@IsString()
@IsHash('sha256')
hash: string;
}

View file

@ -9,5 +9,5 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"sourceMap": true "sourceMap": true
}, },
"include": ["src"] "include": ["src", "../backend/src/decorators/multipart.dto.ts"]
} }