better shared api calls
This commit is contained in:
parent
825b2856bb
commit
626e228991
11
.vscode/tasks.json
vendored
11
.vscode/tasks.json
vendored
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
8
backend/src/backenddto/imageroute.dto.ts
Normal file
8
backend/src/backenddto/imageroute.dto.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { IsDefined, ValidateNested } from 'class-validator';
|
||||||
|
import { MultiPartFileDto } from './multipart.dto';
|
||||||
|
|
||||||
|
export class ImageUploadDto {
|
||||||
|
@IsDefined()
|
||||||
|
@ValidateNested()
|
||||||
|
image: MultiPartFileDto;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
BIN
branding/logo/imagur-128.png
Normal file
BIN
branding/logo/imagur-128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
branding/logo/imagur-512.png
Normal file
BIN
branding/logo/imagur-512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.9 KiB |
BIN
branding/logo/imagur.ico
Normal file
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
61
branding/logo/imagur.svg
Normal 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 |
|
@ -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
1
frontend/public/image/logo
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
/home/rubikscraft/Documents/Projects/Imagur/Imagur-Monorepo/branding/logo
|
|
@ -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" />
|
||||||
|
|
|
@ -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
133
frontend/src/api/api.ts
Normal 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() {}
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
||||||
};
|
|
||||||
}
|
|
11
frontend/src/frontenddto/imageroute.dto.ts
Normal file
11
frontend/src/frontenddto/imageroute.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
13
frontend/src/lib/debounce.ts
Normal file
13
frontend/src/lib/debounce.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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 = () => {
|
||||||
|
|
|
@ -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
45
shared/src/dto/api.dto.ts
Normal 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;
|
|
@ -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;
|
||||||
}
|
}
|
7
shared/src/dto/images.dto.ts
Normal file
7
shared/src/dto/images.dto.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { IsHash, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class ImageUploadResponse {
|
||||||
|
@IsString()
|
||||||
|
@IsHash('sha256')
|
||||||
|
hash: string;
|
||||||
|
}
|
|
@ -9,5 +9,5 @@
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "../backend/src/decorators/multipart.dto.ts"]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue