Compare commits
36 Commits
d907f425dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ef7e372e2 | |||
| 71e232380b | |||
| c2d06446eb | |||
| 1de822da14 | |||
| f735d1631f | |||
| 89b29c58dc | |||
| 60e179cd13 | |||
| 7875c5407c | |||
| 3326b7c589 | |||
| e20231639c | |||
| a0e8506027 | |||
| 26abb6163f | |||
| e7fc6e0802 | |||
| 8ac848e8f1 | |||
| 0bfdded52f | |||
| 03286c2013 | |||
| cc337d22f2 | |||
| bde574ccad | |||
| 6ac9a2f1ec | |||
| 6b010f66ba | |||
| c7ece75e7a | |||
| 4aafe86ef0 | |||
| 4b7417c39b | |||
| d02da321a1 | |||
| d0c074135e | |||
| 7e828b1662 | |||
| 6b5bfa963e | |||
| 969829da20 | |||
| 64ebdfd6f4 | |||
| a44cd89072 | |||
| 8f0ca1ce58 | |||
| cd3ba11924 | |||
| a764e1d441 | |||
| 16f208480d | |||
| abb8bec0cf | |||
| a0909bfd21 |
@@ -11,6 +11,8 @@ DROP TABLE IF EXISTS book_origins;
|
||||
|
||||
DROP TABLE IF EXISTS refresh_tokens;
|
||||
|
||||
DROP TABLE IF EXISTS series_subscriptions;
|
||||
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
DROP TABLE IF EXISTS books;
|
||||
@@ -20,58 +22,64 @@ DROP TABLE IF EXISTS series;
|
||||
CREATE TABLE
|
||||
series (
|
||||
series_id uuid DEFAULT gen_random_uuid (),
|
||||
-- 3rd party used to fetch the data for this series.
|
||||
provider varchar(6) NOT NULL,
|
||||
-- 3rd party id for this series.
|
||||
provider_series_id text,
|
||||
series_title text NOT NULL,
|
||||
date_added timestamp default NULL,
|
||||
PRIMARY KEY (series_id)
|
||||
media_type text,
|
||||
-- 3rd party used to fetch the data for this series.
|
||||
provider varchar(12) NOT NULL,
|
||||
added_at timestamp default NULL,
|
||||
PRIMARY KEY (series_id),
|
||||
UNIQUE (provider, provider_series_id)
|
||||
);
|
||||
|
||||
ALTER TABLE series
|
||||
ALTER COLUMN date_added
|
||||
ALTER COLUMN added_at
|
||||
SET DEFAULT now ();
|
||||
|
||||
CREATE INDEX series_series_title_idx ON series USING HASH (series_title);
|
||||
CREATE INDEX series_series_title_idx ON series (series_title);
|
||||
|
||||
CREATE TABLE
|
||||
books (
|
||||
book_id uuid DEFAULT gen_random_uuid (),
|
||||
series_id uuid,
|
||||
-- 3rd party id for this series if applicable.
|
||||
provider_series_id text,
|
||||
-- 3rd party id for this book.
|
||||
provider_book_id text NOT NULL,
|
||||
isbn varchar(16),
|
||||
book_title text NOT NULL,
|
||||
book_desc text NOT NULL,
|
||||
book_desc text,
|
||||
book_volume integer,
|
||||
date_released timestamp default NULL,
|
||||
date_added timestamp default NULL,
|
||||
-- 3rd party used to fetch the data for this book.
|
||||
provider varchar(12) NOT NULL,
|
||||
published_at timestamp default NULL,
|
||||
added_at timestamp default NULL,
|
||||
PRIMARY KEY (book_id),
|
||||
FOREIGN KEY (series_id) REFERENCES series (series_id)
|
||||
FOREIGN KEY (provider, provider_series_id) REFERENCES series (provider, provider_series_id) ON DELETE CASCADE,
|
||||
UNIQUE NULLS NOT DISTINCT (provider_series_id, provider_book_id, book_volume)
|
||||
);
|
||||
|
||||
ALTER TABLE books
|
||||
ALTER COLUMN date_released
|
||||
ALTER COLUMN added_at
|
||||
SET DEFAULT now ();
|
||||
|
||||
ALTER TABLE books
|
||||
ALTER COLUMN date_added
|
||||
SET DEFAULT now ();
|
||||
-- CREATE INDEX books_series_id_idx ON books (series_id);
|
||||
CREATE INDEX books_provider_provider_series_id_idx ON books (provider, provider_series_id);
|
||||
|
||||
CREATE INDEX books_series_id_idx ON books USING HASH (series_id);
|
||||
|
||||
CREATE INDEX books_isbn_idx ON books USING HASH (isbn);
|
||||
|
||||
CREATE INDEX books_book_title_idx ON books USING HASH (book_title);
|
||||
-- CREATE INDEX books_isbn_idx ON books USING HASH (isbn);
|
||||
CREATE INDEX books_book_title_idx ON books (book_title);
|
||||
|
||||
CREATE TABLE
|
||||
book_origins (
|
||||
book_id uuid,
|
||||
origin_type varchar(8),
|
||||
book_origin_id uuid DEFAULT gen_random_uuid (),
|
||||
book_id uuid NOT NULL,
|
||||
origin_type integer,
|
||||
origin_value text,
|
||||
PRIMARY KEY (book_id, origin_type, origin_value)
|
||||
PRIMARY KEY (book_origin_id),
|
||||
FOREIGN KEY (book_id) REFERENCES books (book_id) ON DELETE CASCADE,
|
||||
UNIQUE (book_id, origin_type, origin_value)
|
||||
);
|
||||
|
||||
CREATE INDEX book_origins_book_id_idx ON book_origins USING HASH (book_id);
|
||||
CREATE INDEX book_origins_book_id_idx ON book_origins (book_id);
|
||||
|
||||
CREATE INDEX book_origins_type_value_idx ON book_origins (origin_type, origin_value);
|
||||
|
||||
@@ -83,58 +91,68 @@ CREATE TABLE
|
||||
password text NOT NULL,
|
||||
salt bigint NOT NULL,
|
||||
is_admin boolean NOT NULL,
|
||||
date_joined timestamp default NULL,
|
||||
joined_at timestamp default NULL,
|
||||
PRIMARY KEY (user_id),
|
||||
UNIQUE (user_login)
|
||||
);
|
||||
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN date_joined
|
||||
ALTER COLUMN joined_at
|
||||
SET DEFAULT now ();
|
||||
|
||||
CREATE INDEX users_user_login_idx ON users USING HASH (user_login);
|
||||
|
||||
CREATE TABLE
|
||||
refresh_tokens (
|
||||
user_id uuid NOT NULL,
|
||||
refresh_token_hash text NOT NULL,
|
||||
exp timestamp NOT NULL,
|
||||
PRIMARY KEY (user_id, refresh_token_hash)
|
||||
PRIMARY KEY (user_id, refresh_token_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE
|
||||
book_statuses (
|
||||
user_id uuid,
|
||||
book_id uuid,
|
||||
state varchar(12),
|
||||
date_added timestamp default NULL,
|
||||
date_modified timestamp default NULL,
|
||||
state smallint,
|
||||
added_at timestamp default NULL,
|
||||
modified_at timestamp default NULL,
|
||||
PRIMARY KEY (user_id, book_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (user_id),
|
||||
FOREIGN KEY (book_id) REFERENCES books (book_id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (book_id) REFERENCES books (book_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE book_statuses
|
||||
ALTER COLUMN date_added
|
||||
ALTER COLUMN added_at
|
||||
SET DEFAULT now ();
|
||||
|
||||
ALTER TABLE book_statuses
|
||||
ALTER COLUMN date_modified
|
||||
SET DEFAULT now ();
|
||||
CREATE INDEX book_statuses_user_id_login_idx ON users (user_id);
|
||||
|
||||
CREATE INDEX book_statuses_user_id_login_idx ON users USING HASH (user_id);
|
||||
CREATE TABLE
|
||||
series_subscriptions (
|
||||
user_id uuid,
|
||||
provider varchar(12) NOT NULL,
|
||||
provider_series_id text,
|
||||
added_at timestamp default NULL,
|
||||
PRIMARY KEY (user_id, provider, provider_series_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (provider, provider_series_id) REFERENCES series (provider, provider_series_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE series_subscriptions
|
||||
ALTER COLUMN added_at
|
||||
SET DEFAULT now ();
|
||||
|
||||
CREATE TABLE
|
||||
api_keys (
|
||||
user_id uuid,
|
||||
api_key char(64),
|
||||
date_added timestamp default NULL,
|
||||
added_at timestamp default NULL,
|
||||
PRIMARY KEY (user_id, api_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users (user_id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE api_keys
|
||||
ALTER COLUMN date_added
|
||||
ALTER COLUMN added_at
|
||||
SET DEFAULT now ();
|
||||
|
||||
CREATE INDEX api_keys_api_key_idx ON api_keys USING HASH (api_key);
|
||||
CREATE INDEX api_keys_api_key_idx ON api_keys (api_key);
|
||||
21
backend/nestjs-seshat-api/assets/config/config.json
Normal file
21
backend/nestjs-seshat-api/assets/config/config.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"features": {
|
||||
"registration": false
|
||||
},
|
||||
"providers": {
|
||||
"default": "google",
|
||||
"google": {
|
||||
"name": "Google",
|
||||
"filters": {},
|
||||
"languages": {
|
||||
"zh": "Chinese",
|
||||
"nl": "Dutch",
|
||||
"en": "English",
|
||||
"fr": "Francais",
|
||||
"ko": "Korean",
|
||||
"ja": "Japanese",
|
||||
"es": "Spanish"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
973
backend/nestjs-seshat-api/package-lock.json
generated
973
backend/nestjs-seshat-api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.0",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
@@ -28,13 +30,18 @@
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"argon2": "^0.41.1",
|
||||
"axios": "^1.7.9",
|
||||
"bullmq": "^5.41.7",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"moment": "^2.30.1",
|
||||
"nestjs-pino": "^4.3.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.13.1",
|
||||
"pino-http": "^10.4.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20",
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import pino from 'pino';
|
||||
import * as path from 'path';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { UsersService } from './users/users.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { DatabaseOptions } from './database-config/database.options';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { UserEntity } from './users/users.entity';
|
||||
import { UserEntity } from './users/entities/users.entity';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { serialize_token, serialize_user_short, serialize_user_long, serialize_res, serialize_req, serialize_job } from './logging.serializers';
|
||||
import { BooksModule } from './books/books.module';
|
||||
import { ProvidersModule } from './providers/providers.module';
|
||||
import { SeriesModule } from './series/series.module';
|
||||
import { LibraryModule } from './library/library.module';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { AssetModule } from './asset/asset.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -16,9 +26,58 @@ import { AuthModule } from './auth/auth.module';
|
||||
imports: [ConfigModule],
|
||||
useClass: DatabaseOptions
|
||||
}),
|
||||
BullModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
connection: {
|
||||
host: config.get('REDIS_HOST') ?? 'localhost',
|
||||
port: config.get('REDIS_PORT') ?? 6379,
|
||||
password: config.get('REDIS_PASSWORD'),
|
||||
}
|
||||
})
|
||||
}),
|
||||
TypeOrmModule.forFeature([UserEntity]),
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
LoggerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (config: ConfigService) => {
|
||||
return {
|
||||
pinoHttp: {
|
||||
level: config.get('LOG_LEVEL') ?? 'info',
|
||||
autoLogging: true,
|
||||
serializers: config.get('ENVIRONMENT', 'production') == 'development' ? {
|
||||
user: value => serialize_user_long(value),
|
||||
access_token: value => serialize_token(value),
|
||||
refresh_token: value => serialize_token(value),
|
||||
job: value => serialize_job(value),
|
||||
req: value => serialize_req(value),
|
||||
res: value => serialize_res(value),
|
||||
} : {
|
||||
user: value => serialize_user_short(value),
|
||||
access_token: value => serialize_token(value),
|
||||
refresh_token: value => serialize_token(value),
|
||||
job: value => serialize_job(value),
|
||||
req: value => serialize_req(value),
|
||||
res: value => serialize_res(value),
|
||||
},
|
||||
stream: pino.destination({
|
||||
dest: path.join(config.get('LOG_DIRECTORY') ?? 'logs', 'backend.api.log'),
|
||||
minLength: 512,
|
||||
sync: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
BooksModule,
|
||||
ProvidersModule,
|
||||
SeriesModule,
|
||||
LibraryModule,
|
||||
ConfigModule,
|
||||
AssetModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService, UsersService],
|
||||
|
||||
7
backend/nestjs-seshat-api/src/asset/asset.module.ts
Normal file
7
backend/nestjs-seshat-api/src/asset/asset.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigController } from './config/config.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ConfigController]
|
||||
})
|
||||
export class AssetModule {}
|
||||
7
backend/nestjs-seshat-api/src/asset/config/app-config.ts
Normal file
7
backend/nestjs-seshat-api/src/asset/config/app-config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const file_path = path.join(process.cwd(), './assets/config/config.json');
|
||||
const file_content = fs.readFileSync(file_path).toString();
|
||||
|
||||
export const AppConfig = JSON.parse(file_content);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigController } from './config.controller';
|
||||
|
||||
describe('ConfigController', () => {
|
||||
let controller: ConfigController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ConfigController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<ConfigController>(ConfigController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Controller, Request, Get } from '@nestjs/common';
|
||||
import { AppConfig } from './app-config';
|
||||
|
||||
@Controller('asset')
|
||||
export class ConfigController {
|
||||
@Get('config')
|
||||
async config(
|
||||
@Request() req,
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
config: AppConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,57 @@
|
||||
import * as moment from 'moment';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { UserEntity } from 'src/users/users.entity';
|
||||
import { UserEntity } from 'src/users/entities/users.entity';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { AccessTokenDto } from './dto/access-token.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthAccessService {
|
||||
constructor(
|
||||
private jwts: JwtService,
|
||||
private config: ConfigService,
|
||||
private logger: PinoLogger,
|
||||
) { }
|
||||
|
||||
async generate(user: UserEntity) {
|
||||
async generate(user: UserEntity): Promise<AccessTokenDto> {
|
||||
const now = new Date();
|
||||
const limit = parseInt(this.config.getOrThrow('AUTH_JWT_ACCESS_TOKEN_EXPIRATION_MS'));
|
||||
const limit = parseInt(this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_EXPIRATION_MS'));
|
||||
const expiration = moment(now).add(limit, 'ms').toDate();
|
||||
|
||||
const token = await this.jwts.signAsync(
|
||||
{
|
||||
username: user.userLogin,
|
||||
sub: user.userId,
|
||||
iat: now.getTime(),
|
||||
nbf: now.getTime(),
|
||||
exp: expiration.getTime(),
|
||||
iat: Math.floor(now.getTime() / 1000),
|
||||
nbf: Math.floor(now.getTime() / 1000) - 5 * 60,
|
||||
exp: Math.floor(expiration.getTime() / 1000),
|
||||
},
|
||||
{
|
||||
secret: this.config.getOrThrow('AUTH_JWT_ACCESS_TOKEN_SECRET'),
|
||||
secret: this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET'),
|
||||
}
|
||||
);
|
||||
|
||||
this.logger.debug({
|
||||
class: AuthAccessService.name,
|
||||
method: this.generate.name,
|
||||
user,
|
||||
access_token: token,
|
||||
exp: expiration,
|
||||
msg: 'User generated an access token.',
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: token,
|
||||
exp: expiration.getTime(),
|
||||
}
|
||||
}
|
||||
|
||||
async verify(token: string) {
|
||||
return await this.jwts.verifyAsync(token,
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET')
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,187 @@
|
||||
import { Controller, Request, Post, UseGuards, Get, Body, Res } from '@nestjs/common';
|
||||
import { LoginAuthGuard } from './guards/login-auth.guard';
|
||||
import { Controller, Request, Post, UseGuards, Body, Res, Delete, Patch, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
import { RegisterUserDto } from './dto/register-user.dto';
|
||||
import { Response } from 'express';
|
||||
import { JwtRefreshGuard } from './guards/jwt-refresh.guard';
|
||||
import { OfflineGuard } from './guards/offline.guard';
|
||||
import { UserEntity } from 'src/users/entities/users.entity';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AuthenticationDto } from './dto/authentication.dto';
|
||||
import { AppConfig } from 'src/asset/config/app-config';
|
||||
import { JwtMixedGuard } from './guards/jwt-mixed.guard';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private auth: AuthService, private users: UsersService) { }
|
||||
constructor(
|
||||
private auth: AuthService,
|
||||
private users: UsersService,
|
||||
private logger: PinoLogger,
|
||||
) { }
|
||||
|
||||
@UseGuards(LoginAuthGuard)
|
||||
@UseGuards(OfflineGuard)
|
||||
@Post('login')
|
||||
async login(
|
||||
@Request() req,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Body() body: LoginDto,
|
||||
) {
|
||||
let data: AuthenticationDto | null;
|
||||
try {
|
||||
const data = await this.auth.login(req.user);
|
||||
data = await this.auth.login(body);
|
||||
if (!data.access_token || body.remember_me && (!data.refresh_token || !data.refresh_exp)) {
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong with tokens while logging in.',
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({
|
||||
class: AuthController.name,
|
||||
method: this.login.name,
|
||||
user: req.user,
|
||||
msg: 'Failed to login.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
if (err instanceof UnauthorizedException) {
|
||||
response.statusCode = 401;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Invalid credentials.',
|
||||
};
|
||||
}
|
||||
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong while logging in.',
|
||||
};
|
||||
}
|
||||
|
||||
response.cookie('Authentication', data.access_token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: new Date(data.exp),
|
||||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
if (body.remember_me) {
|
||||
response.cookie('Refresh', data.refresh_token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: new Date(data.refresh_exp),
|
||||
sameSite: 'strict',
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
class: AuthController.name,
|
||||
method: this.login.name,
|
||||
user: req.user,
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
remember_me: body.remember_me,
|
||||
msg: 'User logged in.',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong.',
|
||||
}
|
||||
}
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(LoginAuthGuard)
|
||||
@Post('logout')
|
||||
async logout(@Request() req) {
|
||||
return req.logout();
|
||||
@UseGuards(JwtMixedGuard)
|
||||
@Delete('login')
|
||||
async logout(
|
||||
@Request() req,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const accessToken = req.cookies?.Authentication;
|
||||
const refreshToken = req.cookies?.Refresh;
|
||||
|
||||
response.clearCookie('Authentication');
|
||||
response.clearCookie('Refresh');
|
||||
|
||||
if (!accessToken && !refreshToken && !await this.auth.revoke(req.user.userId, refreshToken)) {
|
||||
// User has already logged off.
|
||||
this.logger.info({
|
||||
class: AuthController.name,
|
||||
method: this.logout.name,
|
||||
user: req.user,
|
||||
msg: 'User has already logged off based on ' + (!refreshToken ? 'cookies' : 'database'),
|
||||
});
|
||||
|
||||
response.statusCode = 400;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'User has already logged off.',
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
class: AuthController.name,
|
||||
method: this.logout.name,
|
||||
user: req.user,
|
||||
msg: 'User logged off',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtRefreshGuard)
|
||||
@Post('refresh')
|
||||
@Patch('login')
|
||||
async refresh(
|
||||
@Request() req,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
try {
|
||||
const refresh_token = req.cookies.Refresh;
|
||||
const data = await this.auth.renew(req.user, refresh_token);
|
||||
this.logger.info({
|
||||
class: AuthController.name,
|
||||
method: this.refresh.name,
|
||||
user: req.user,
|
||||
refresh_token: req.cookies.Refresh,
|
||||
msg: 'Attempting to renew access token.',
|
||||
});
|
||||
|
||||
const results = await this.auth.verify(req.cookies.Authentication, req.cookies.Refresh);
|
||||
|
||||
if (results.validation === false) {
|
||||
this.logger.info({
|
||||
class: AuthController.name,
|
||||
method: this.refresh.name,
|
||||
user: req.user,
|
||||
refresh_token: req.cookies.Refresh,
|
||||
msg: 'Refresh token is invalid. Access token is not refreshing.',
|
||||
});
|
||||
|
||||
response.statusCode = 400;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Refresh token is invalid.',
|
||||
};
|
||||
}
|
||||
|
||||
const data = await this.auth.renew(req.user);
|
||||
|
||||
response.cookie('Authentication', data.access_token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: new Date(data.exp),
|
||||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
if (data.refresh_token != refresh_token) {
|
||||
response.cookie('Refresh', data.refresh_token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: new Date(data.refresh_exp),
|
||||
this.logger.debug({
|
||||
class: AuthController.name,
|
||||
method: this.refresh.name,
|
||||
user: req.user,
|
||||
access_token: data.access_token,
|
||||
msg: 'Updated Authentication cookie for access token.',
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(OfflineGuard)
|
||||
@@ -89,67 +191,136 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Body() body: RegisterUserDto,
|
||||
) {
|
||||
if (!AppConfig.features.registration) {
|
||||
response.statusCode = 404;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Registration disabled.',
|
||||
};
|
||||
}
|
||||
|
||||
let user: UserEntity | null;
|
||||
let data: AuthenticationDto | null;
|
||||
try {
|
||||
const { user_login, user_name, password } = body;
|
||||
if (!user_login) {
|
||||
return { success: false, error_message: 'No user login found.' };
|
||||
user = await this.users.register(user_login.toLowerCase(), user_name, password, true);
|
||||
this.logger.info({
|
||||
class: AuthController.name,
|
||||
method: this.register.name,
|
||||
user: req.user,
|
||||
msg: 'User registered',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof QueryFailedError) {
|
||||
if (err.message.includes('duplicate key value violates unique constraint "users_user_login_key"')) {
|
||||
this.logger.warn({
|
||||
class: AuthController.name,
|
||||
method: this.register.name,
|
||||
user: req.user,
|
||||
msg: 'Failed to register due to duplicate userLogin.',
|
||||
});
|
||||
|
||||
response.statusCode = 409;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Username already exist.',
|
||||
};
|
||||
}
|
||||
if (!user_name) {
|
||||
return { success: false, error_message: 'No user name found.' };
|
||||
}
|
||||
if (!password) {
|
||||
return { success: false, error_message: 'No password found.' };
|
||||
}
|
||||
if (user_name.length < 1) {
|
||||
return { success: false, error_message: 'Name is too short.' };
|
||||
}
|
||||
if (user_name.length > 32) {
|
||||
return { success: false, error_message: 'Name is too long.' };
|
||||
}
|
||||
if (user_login.length < 3) {
|
||||
return { success: false, error_message: 'Login is too short.' };
|
||||
}
|
||||
if (user_login.length > 12) {
|
||||
return { success: false, error_message: 'Login is too long.' };
|
||||
}
|
||||
if (password.length < 12) {
|
||||
return { success: false, error_message: 'Password is too short.' };
|
||||
}
|
||||
if (password.length > 64) {
|
||||
return { success: false, error_message: 'Password is too long.' };
|
||||
this.logger.error({
|
||||
class: AuthController.name,
|
||||
method: this.register.name,
|
||||
user: req.user,
|
||||
msg: 'Failed to register.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong when creating user.',
|
||||
};
|
||||
}
|
||||
|
||||
const user = await this.users.register(user_login.toLowerCase(), user_name, password, true);
|
||||
if (!user) {
|
||||
return { success: false, error_message: 'Failed to register' };
|
||||
try {
|
||||
data = await this.auth.login({
|
||||
user_login: body.user_login,
|
||||
password: body.password,
|
||||
remember_me: false,
|
||||
});
|
||||
if (!data.access_token) {
|
||||
this.logger.error({
|
||||
class: AuthController.name,
|
||||
method: this.register.name,
|
||||
user: req.user,
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
msg: 'Failed to generate tokens after registering.',
|
||||
});
|
||||
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong with tokens while logging in.',
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({
|
||||
class: AuthController.name,
|
||||
method: this.register.name,
|
||||
user: req.user,
|
||||
msg: 'Failed to login after registering.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
// This should never happen...
|
||||
if (err instanceof UnauthorizedException) {
|
||||
response.statusCode = 401;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Invalid credentials.',
|
||||
};
|
||||
}
|
||||
|
||||
const data = await this.auth.login(user);
|
||||
if (!data.access_token || !data.refresh_token || !data.refresh_exp) {
|
||||
return { success: false, error_message: 'Something went wrong while logging in.' };
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong while logging in.',
|
||||
};
|
||||
}
|
||||
|
||||
response.cookie('Authentication', data.access_token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: new Date(data.exp),
|
||||
});
|
||||
|
||||
response.cookie('Refresh', data.refresh_token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: new Date(data.refresh_exp),
|
||||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('validate')
|
||||
async validate(
|
||||
@Request() req,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
try {
|
||||
const accessToken = req.cookies['Authentication'];
|
||||
const refreshToken = req.cookies['Refresh'];
|
||||
const verification = await this.auth.verify(accessToken, refreshToken);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...verification,
|
||||
};
|
||||
} catch (err) {
|
||||
console.log('AuthController', err);
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong.',
|
||||
}
|
||||
error_message: err,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,23 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UsersModule } from 'src/users/users.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { LoginStrategy } from './strategies/login.strategy';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { JwtOptions } from './jwt.options';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtAccessStrategy } from './strategies/jwt-access.strategy';
|
||||
import { AuthRefreshService } from './auth.refresh.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthRefreshTokenEntity } from './entities/auth.refresh-token.entity';
|
||||
import { AuthAccessService } from './auth.access.service';
|
||||
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AuthRefreshTokenEntity]),
|
||||
ConfigModule,
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
PassportModule.register({ session: false }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
extraProviders: [ConfigService],
|
||||
@@ -34,8 +34,8 @@ import { AuthAccessService } from './auth.access.service';
|
||||
AuthAccessService,
|
||||
AuthRefreshService,
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
LoginStrategy,
|
||||
JwtAccessStrategy,
|
||||
JwtRefreshStrategy,
|
||||
],
|
||||
controllers: [AuthController]
|
||||
})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as moment from "moment";
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { UUID } from 'crypto';
|
||||
import { UserEntity } from 'src/users/users.entity';
|
||||
import { UserEntity } from 'src/users/entities/users.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuthRefreshTokenEntity } from './entities/auth.refresh-token.entity';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
@Injectable()
|
||||
export class AuthRefreshService {
|
||||
@@ -15,49 +16,50 @@ export class AuthRefreshService {
|
||||
private jwts: JwtService,
|
||||
private config: ConfigService,
|
||||
@InjectRepository(AuthRefreshTokenEntity)
|
||||
private authRefreshTokenRepository: Repository<AuthRefreshTokenEntity>
|
||||
private authRefreshTokenRepository: Repository<AuthRefreshTokenEntity>,
|
||||
private logger: PinoLogger,
|
||||
) { }
|
||||
|
||||
|
||||
async generate(user: UserEntity, refreshToken?: string) {
|
||||
let expiration: Date | null = null;
|
||||
if (refreshToken) {
|
||||
const token = await this.get(refreshToken, user.userId);
|
||||
if (token.exp.getTime() > new Date().getTime()) {
|
||||
throw new UnauthorizedException('Invalid refresh token.');
|
||||
}
|
||||
|
||||
expiration = token.exp;
|
||||
}
|
||||
|
||||
// Generate new refresh token if either:
|
||||
// - no previous token exists;
|
||||
// - token has reached expiration threshold;
|
||||
// - token has expired.
|
||||
async generate(user: UserEntity) {
|
||||
const now = new Date();
|
||||
const threshhold = parseInt(this.config.getOrThrow('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_THRESHHOLD_MS'));
|
||||
if (!refreshToken || !expiration || now.getTime() - expiration.getTime() > threshhold) {
|
||||
const limit = parseInt(this.config.getOrThrow('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_MS'));
|
||||
expiration = moment(now).add(limit, 'ms').toDate();
|
||||
refreshToken = await this.jwts.signAsync(
|
||||
const expirationTime = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_MS'));
|
||||
const expiration = moment(now).add(expirationTime, 'ms').toDate();
|
||||
const refreshToken = await this.jwts.signAsync(
|
||||
{
|
||||
username: user.userLogin,
|
||||
sub: user.userId,
|
||||
iat: now.getTime(),
|
||||
nbf: now.getTime(),
|
||||
exp: expiration.getTime(),
|
||||
iat: Math.floor(now.getTime() / 1000),
|
||||
nbf: Math.floor(now.getTime() / 1000) - 5 * 60,
|
||||
exp: Math.floor(expiration.getTime() / 1000),
|
||||
},
|
||||
{
|
||||
secret: this.config.getOrThrow('AUTH_JWT_REFRESH_TOKEN_SECRET'),
|
||||
secret: this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_SECRET'),
|
||||
}
|
||||
);
|
||||
|
||||
this.logger.debug({
|
||||
class: AuthRefreshService.name,
|
||||
method: this.generate.name,
|
||||
user,
|
||||
refresh_token: refreshToken,
|
||||
exp: expiration,
|
||||
msg: 'Generated a new refresh token.',
|
||||
});
|
||||
|
||||
this.authRefreshTokenRepository.insert({
|
||||
tokenHash: refreshToken,
|
||||
tokenHash: this.hash(refreshToken),
|
||||
userId: user.userId,
|
||||
exp: expiration
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.debug({
|
||||
class: AuthRefreshService.name,
|
||||
method: this.generate.name,
|
||||
user,
|
||||
refresh_token: refreshToken,
|
||||
exp: expiration,
|
||||
msg: 'Inserted the new refresh token into the database.',
|
||||
});
|
||||
|
||||
return {
|
||||
refresh_token: refreshToken,
|
||||
@@ -73,15 +75,28 @@ export class AuthRefreshService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(refreshToken, 'utf8');
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('base64');
|
||||
|
||||
return await this.authRefreshTokenRepository.findOneBy({
|
||||
tokenHash: hash,
|
||||
tokenHash: this.hash(refreshToken),
|
||||
userId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
private hash(refreshToken: string): string {
|
||||
const buffer = Buffer.from(refreshToken, 'utf8');
|
||||
return crypto.createHash('sha256').update(buffer).digest('base64');
|
||||
}
|
||||
|
||||
async revoke(userId: UUID, refreshToken: string) {
|
||||
if (!userId || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.authRefreshTokenRepository.delete({
|
||||
userId,
|
||||
tokenHash: this.hash(refreshToken),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(
|
||||
refreshToken: string,
|
||||
userId: UUID,
|
||||
@@ -89,4 +104,14 @@ export class AuthRefreshService {
|
||||
const refresh = await this.get(refreshToken, userId);
|
||||
return refresh && refresh.exp.getTime() > new Date().getTime();
|
||||
}
|
||||
|
||||
async verify(
|
||||
refreshToken: string
|
||||
): Promise<any> {
|
||||
return await this.jwts.verifyAsync(refreshToken,
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_SECRET'),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UserEntity } from 'src/users/users.entity';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { UserEntity } from 'src/users/entities/users.entity';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
import { AuthRefreshService } from './auth.refresh.service';
|
||||
import { AuthAccessService } from './auth.access.service';
|
||||
import { UUID } from 'crypto';
|
||||
import { AuthenticationDto } from './dto/authentication.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AccessTokenDto } from './dto/access-token.dto';
|
||||
import { TokenExpiredError } from '@nestjs/jwt';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -13,30 +18,127 @@ export class AuthService {
|
||||
) { }
|
||||
|
||||
|
||||
async login(user: UserEntity) {
|
||||
return this.renew(user, null);
|
||||
async login(
|
||||
loginDetails: LoginDto
|
||||
): Promise<AuthenticationDto> {
|
||||
const user = await this.users.findOne(loginDetails);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const access_token = await this.accessTokens.generate(user);
|
||||
|
||||
if (!loginDetails.remember_me) {
|
||||
return {
|
||||
...access_token,
|
||||
refresh_token: null,
|
||||
refresh_exp: null,
|
||||
}
|
||||
}
|
||||
|
||||
const refresh_token = await this.refreshTokens.generate(user);
|
||||
return {
|
||||
...access_token,
|
||||
refresh_token: refresh_token.refresh_token,
|
||||
refresh_exp: refresh_token.exp,
|
||||
}
|
||||
}
|
||||
|
||||
async renew(
|
||||
user: UserEntity,
|
||||
): Promise<AccessTokenDto> {
|
||||
return await this.accessTokens.generate(user);
|
||||
}
|
||||
|
||||
async validate(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<UserEntity | null> {
|
||||
return await this.users.findOne({ username, password });
|
||||
return await this.users.findOne({ user_login: username, password, remember_me: false });
|
||||
}
|
||||
|
||||
async renew(
|
||||
user: UserEntity,
|
||||
refresh_token: string
|
||||
): Promise<{ access_token: string, exp: number, refresh_token: string, refresh_exp: number }> {
|
||||
const new_refresh_data = await this.refreshTokens.generate(user, refresh_token);
|
||||
const new_refresh_token = new_refresh_data.refresh_token;
|
||||
const new_refresh_exp = new_refresh_data.exp;
|
||||
const access_token = await this.accessTokens.generate(user);
|
||||
async verify(
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
): Promise<{ validation: boolean, userId: UUID | null, username: string | null }> {
|
||||
let access: any = null;
|
||||
let refresh: any = null;
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
access = await this.accessTokens.verify(accessToken);
|
||||
} catch (err) {
|
||||
if (!(err instanceof TokenExpiredError)) {
|
||||
return {
|
||||
validation: false,
|
||||
userId: null,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (access && (!access.username || !access.sub)) {
|
||||
return {
|
||||
validation: false,
|
||||
userId: null,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
refresh = await this.refreshTokens.verify(refreshToken);
|
||||
} catch (err) {
|
||||
return {
|
||||
validation: false,
|
||||
userId: null,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!refresh.username || !refresh.sub) {
|
||||
return {
|
||||
validation: false,
|
||||
userId: null,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!access && !refresh) {
|
||||
return {
|
||||
validation: false,
|
||||
userId: null,
|
||||
username: null,
|
||||
};
|
||||
} else if (!access && refresh) {
|
||||
return {
|
||||
validation: null,
|
||||
userId: null,
|
||||
username: null,
|
||||
};
|
||||
} else if (access && refresh) {
|
||||
if (access.username != refresh.username || access.sub != refresh.sub) {
|
||||
return {
|
||||
validation: false,
|
||||
userId: null,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...access_token,
|
||||
refresh_token: new_refresh_token,
|
||||
refresh_exp: new_refresh_exp,
|
||||
}
|
||||
validation: true,
|
||||
userId: (access ?? refresh).sub,
|
||||
username: (access ?? refresh).username,
|
||||
};
|
||||
}
|
||||
|
||||
async revoke(
|
||||
userId: UUID,
|
||||
refreshToken: string
|
||||
): Promise<boolean> {
|
||||
const res = await this.refreshTokens.revoke(userId, refreshToken);
|
||||
return res?.affected === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export class AccessTokenDto {
|
||||
access_token: string;
|
||||
exp: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class AuthenticationDto {
|
||||
access_token: string;
|
||||
exp: number;
|
||||
refresh_token: string | null;
|
||||
refresh_exp: number | null;
|
||||
}
|
||||
19
backend/nestjs-seshat-api/src/auth/dto/login.dto.ts
Normal file
19
backend/nestjs-seshat-api/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
@MaxLength(24)
|
||||
readonly user_login: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
readonly password: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
readonly remember_me: boolean;
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { IsAlphanumeric, IsNotEmpty, IsString, Length, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterUserDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@Length(3, 16)
|
||||
@IsAlphanumeric()
|
||||
readonly user_login: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@Length(1, 32)
|
||||
readonly user_name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(12, 64)
|
||||
@MinLength(12)
|
||||
@MaxLength(64)
|
||||
readonly password: string;
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { UUID } from 'crypto';
|
||||
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity("refresh_tokens")
|
||||
export class AuthRefreshTokenEntity {
|
||||
@PrimaryColumn({ name: 'user_id' })
|
||||
@IsNotEmpty()
|
||||
readonly userId: UUID;
|
||||
|
||||
@PrimaryColumn({ name: 'refresh_token_hash' })
|
||||
@IsNotEmpty()
|
||||
tokenHash: string;
|
||||
|
||||
@Column()
|
||||
@IsNotEmpty()
|
||||
@Column({ name: 'exp' })
|
||||
exp: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
|
||||
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAccessAdminGuard extends AuthGuard('jwt') {
|
||||
canActivate(context: ExecutionContext) {
|
||||
// Add your custom authentication logic here
|
||||
// for example, call super.logIn(request) to establish a session.
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
export class JwtAccessAdminGuard extends AuthGuard('jwt-access') {
|
||||
handleRequest(err, user, info) {
|
||||
// You can throw an exception based on either "info" or "err" arguments
|
||||
if (err || !user || !user.isAdmin) {
|
||||
throw err || new UnauthorizedException();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAccessGuard extends AuthGuard('jwt') { }
|
||||
export class JwtAccessGuard extends AuthGuard('jwt-access') {
|
||||
handleRequest(err, user, info) {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
12
backend/nestjs-seshat-api/src/auth/guards/jwt-mixed.guard.ts
Normal file
12
backend/nestjs-seshat-api/src/auth/guards/jwt-mixed.guard.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtMixedGuard extends AuthGuard(['jwt-access', 'jwt-refresh']) {
|
||||
handleRequest(err, user, info) {
|
||||
if (err || !user) {
|
||||
throw err || new ForbiddenException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class LoginAuthGuard extends AuthGuard('login') { }
|
||||
@@ -1,13 +1,13 @@
|
||||
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class OfflineGuard implements CanActivate {
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
return !request.user;
|
||||
export class OfflineGuard extends AuthGuard(['jwt-access', 'jwt-refresh']) {
|
||||
handleRequest(err, user, info) {
|
||||
if (err || user) {
|
||||
throw err || new ForbiddenException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ export class JwtOptions implements JwtOptionsFactory {
|
||||
createJwtOptions(): Promise<JwtModuleOptions> | JwtModuleOptions {
|
||||
return {
|
||||
signOptions: {
|
||||
issuer: this.config.getOrThrow('AUTH_JWT_ISSUER'),
|
||||
audience: this.config.getOrThrow('AUTH_JWT_AUDIENCE'),
|
||||
issuer: this.config.getOrThrow<string>('AUTH_JWT_ISSUER'),
|
||||
audience: this.config.getOrThrow<string>('AUTH_JWT_AUDIENCE'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') {
|
||||
constructor(private users: UsersService, private config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
//ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
JwtAccessStrategy.extract,
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET'),
|
||||
issuer: config.getOrThrow<string>('AUTH_JWT_ISSUER'),
|
||||
audience: config.getOrThrow<string>('AUTH_JWT_AUDIENCE'),
|
||||
});
|
||||
}
|
||||
|
||||
private static extract(req: any): string | null {
|
||||
const jwt = req.cookies?.Authentication;
|
||||
if (!jwt)
|
||||
return null;
|
||||
|
||||
return jwt;
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
if (!payload) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (payload.iss != this.config.getOrThrow('AUTH_JWT_ISSUER')) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (payload.aud != this.config.getOrThrow('AUTH_JWT_AUDIENCE')) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const user = await this.users.findById(payload.sub);
|
||||
if (!user || user.userLogin != payload.username) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,54 @@
|
||||
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthRefreshService } from '../auth.refresh.service';
|
||||
import { Request } from 'express';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||
constructor(private auth: AuthRefreshService, private config: ConfigService) {
|
||||
constructor(private auth: AuthRefreshService, private users: UsersService, private config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
//ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
JwtRefreshStrategy.extract,
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get('AUTH_JWT_REFRESH_SECRET'),
|
||||
issuer: config.getOrThrow('AUTH_JWT_ISSUER'),
|
||||
audience: config.getOrThrow('AUTH_JWT_AUDIENCE'),
|
||||
secretOrKey: config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_SECRET'),
|
||||
issuer: config.getOrThrow<string>('AUTH_JWT_ISSUER'),
|
||||
audience: config.getOrThrow<string>('AUTH_JWT_AUDIENCE'),
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
private static extract(req: any): string | null {
|
||||
const jwt = req.cookies?.Refresh;
|
||||
if (!jwt)
|
||||
return null;
|
||||
|
||||
return jwt;
|
||||
}
|
||||
|
||||
async validate(request: Request, payload: any) {
|
||||
return this.auth.validate(request.cookies?.Refresh, payload.sub);
|
||||
const user = await this.users.findById(payload.sub);
|
||||
if (!user || user.userLogin != payload.username) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (payload.iss != this.config.getOrThrow('AUTH_JWT_ISSUER')) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (payload.aud != this.config.getOrThrow('AUTH_JWT_AUDIENCE')) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const refreshToken = request.cookies?.Refresh;
|
||||
if (!refreshToken || !this.auth.validate(refreshToken, payload.sub)) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(private users: UsersService, private config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow('AUTH_JWT_ACCESS_TOKEN_SECRET'),
|
||||
issuer: config.getOrThrow('AUTH_JWT_ISSUER'),
|
||||
audience: config.getOrThrow('AUTH_JWT_AUDIENCE'),
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(req: Request, payload: any) {
|
||||
console.log('jwt payload', payload);
|
||||
const user = await this.users.findById(payload.sub);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
|
||||
import { Strategy } from 'passport-local';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class LoginStrategy extends PassportStrategy(Strategy, 'login') {
|
||||
constructor(private authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(username: string, password: string): Promise<any> {
|
||||
const user = await this.authService.validate(username, password);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
26
backend/nestjs-seshat-api/src/books/books.module.ts
Normal file
26
backend/nestjs-seshat-api/src/books/books.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BooksService } from './books.service';
|
||||
import { BookEntity } from './entities/book.entity';
|
||||
import { BookOriginEntity } from './entities/book-origin.entity';
|
||||
import { BookStatusEntity } from './entities/book-status.entity';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { SeriesModule } from 'src/series/series.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
BookEntity,
|
||||
BookOriginEntity,
|
||||
BookStatusEntity,
|
||||
]),
|
||||
SeriesModule,
|
||||
HttpModule,
|
||||
],
|
||||
controllers: [],
|
||||
exports: [
|
||||
BooksService
|
||||
],
|
||||
providers: [BooksService]
|
||||
})
|
||||
export class BooksModule { }
|
||||
18
backend/nestjs-seshat-api/src/books/books.service.spec.ts
Normal file
18
backend/nestjs-seshat-api/src/books/books.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BooksService } from './books.service';
|
||||
|
||||
describe('BooksService', () => {
|
||||
let service: BooksService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [BooksService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BooksService>(BooksService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
123
backend/nestjs-seshat-api/src/books/books.service.ts
Normal file
123
backend/nestjs-seshat-api/src/books/books.service.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { BookEntity } from './entities/book.entity';
|
||||
import { DeleteResult, In, InsertResult, Repository } from 'typeorm';
|
||||
import { BookOriginEntity } from './entities/book-origin.entity';
|
||||
import { BookStatusEntity } from './entities/book-status.entity';
|
||||
import { UUID } from 'crypto';
|
||||
import { CreateBookDto } from './dto/create-book.dto';
|
||||
import { CreateBookOriginDto } from './dto/create-book-origin.dto';
|
||||
import { CreateBookStatusDto } from './dto/create-book-status.dto';
|
||||
import { DeleteBookStatusDto } from './dto/delete-book-status.dto';
|
||||
import { SeriesDto } from 'src/series/dto/series.dto';
|
||||
import { SeriesSubscriptionDto } from 'src/series/dto/series-subscription.dto';
|
||||
import { BookOriginDto } from './dto/book-origin.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BooksService {
|
||||
constructor(
|
||||
@InjectRepository(BookEntity)
|
||||
private bookRepository: Repository<BookEntity>,
|
||||
@InjectRepository(BookOriginEntity)
|
||||
private bookOriginRepository: Repository<BookOriginEntity>,
|
||||
@InjectRepository(BookStatusEntity)
|
||||
private bookStatusRepository: Repository<BookStatusEntity>,
|
||||
) { }
|
||||
|
||||
|
||||
async createBook(book: CreateBookDto): Promise<InsertResult> {
|
||||
const entity = this.bookRepository.create(book);
|
||||
return await this.bookRepository.createQueryBuilder()
|
||||
.insert()
|
||||
.into(BookEntity)
|
||||
.values(entity)
|
||||
.returning('book_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async addBookOrigin(origin: CreateBookOriginDto): Promise<InsertResult> {
|
||||
return await this.bookOriginRepository.insert(origin);
|
||||
}
|
||||
|
||||
async deleteBookOrigin(origin: BookOriginDto[]): Promise<DeleteResult> {
|
||||
return await this.bookOriginRepository.createQueryBuilder()
|
||||
.delete()
|
||||
.where({
|
||||
whereFactory: {
|
||||
bookOriginId: In(origin.map(o => o.bookOriginId)),
|
||||
},
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteBookStatus(status: DeleteBookStatusDto): Promise<DeleteResult> {
|
||||
return await this.bookStatusRepository.createQueryBuilder()
|
||||
.delete()
|
||||
.where({
|
||||
whereFactory: status,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findBooksByIds(bookIds: UUID[]): Promise<BookEntity[]> {
|
||||
return await this.bookRepository.find({
|
||||
where: {
|
||||
bookId: In(bookIds)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findBooksFromSeries(series: SeriesDto): Promise<BookEntity[]> {
|
||||
return await this.bookRepository.find({
|
||||
where: {
|
||||
providerSeriesId: series.providerSeriesId,
|
||||
provider: series.provider,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findBooks(): Promise<BookEntity[]> {
|
||||
return await this.bookRepository.find();
|
||||
}
|
||||
|
||||
async findActualBookStatusesTrackedBy(userId: UUID, series: SeriesDto): Promise<BookStatusEntity[]> {
|
||||
return await this.bookStatusRepository.createQueryBuilder('s')
|
||||
.innerJoin('s.book', 'b')
|
||||
.where('s.user_id = :id', { id: userId })
|
||||
.andWhere('b.provider = :provider', { provider: series.provider })
|
||||
.andWhere('b.providerSeriesId = :id', { id: series.providerSeriesId })
|
||||
.addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider', 'b.providerSeriesId'])
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findBookStatusesTrackedBy(subscription: SeriesSubscriptionDto): Promise<any> {
|
||||
return await this.bookRepository.createQueryBuilder('b')
|
||||
.where('b.provider = :provider', { provider: subscription.provider })
|
||||
.andWhere(`b.provider_series_id = :id`, { id: subscription.providerSeriesId })
|
||||
.leftJoin('b.statuses', 's')
|
||||
.where(`s.user_id = :id`, { id: subscription.userId })
|
||||
.addSelect(['s.state'])
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async updateBook(bookId: UUID, update: CreateBookDto) {
|
||||
return await this.bookRepository.update({
|
||||
bookId,
|
||||
}, update);
|
||||
}
|
||||
|
||||
async updateBookOrigin(bookOriginId: UUID, update: CreateBookOriginDto) {
|
||||
return await this.bookOriginRepository.update({
|
||||
bookOriginId
|
||||
}, update);
|
||||
}
|
||||
|
||||
async updateBookStatus(status: CreateBookStatusDto) {
|
||||
status.modifiedAt = new Date();
|
||||
await this.bookStatusRepository.createQueryBuilder()
|
||||
.insert()
|
||||
.values(status)
|
||||
.orUpdate(['state', 'modified_at'], ['user_id', 'book_id'])
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class BookOriginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
bookOriginId: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
|
||||
import { UUID } from 'crypto';
|
||||
import { BookOriginType } from 'src/shared/enums/book_origin_type';
|
||||
|
||||
export class CreateBookOriginDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
readonly bookId: UUID;
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
@Transform(({ value }) => value as BookOriginType)
|
||||
type: BookOriginType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
export class CreateBookStatusDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
readonly bookId: UUID;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
readonly userId: UUID;
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
@Min(0)
|
||||
@Max(6)
|
||||
state: number;
|
||||
|
||||
modifiedAt: Date;
|
||||
}
|
||||
35
backend/nestjs-seshat-api/src/books/dto/create-book.dto.ts
Normal file
35
backend/nestjs-seshat-api/src/books/dto/create-book.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateBookDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
providerSeriesId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
providerBookId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
desc: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@IsPositive()
|
||||
volume: number | null;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
provider: string;
|
||||
|
||||
@IsDate()
|
||||
@IsNotEmpty()
|
||||
@Transform(({ value }) => new Date(value))
|
||||
publishedAt: Date
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
export class DeleteBookStatusDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
readonly bookId: UUID;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
readonly userId: UUID;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
|
||||
import { UUID } from 'crypto';
|
||||
import { BookOriginType } from 'src/shared/enums/book_origin_type';
|
||||
|
||||
export class UpdateBookOriginDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
readonly bookOriginId: UUID;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
readonly bookId: UUID;
|
||||
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
@Transform(({ value }) => value as BookOriginType)
|
||||
type: BookOriginType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
value: string;
|
||||
}
|
||||
40
backend/nestjs-seshat-api/src/books/dto/update-book.dto.ts
Normal file
40
backend/nestjs-seshat-api/src/books/dto/update-book.dto.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
export class UpdateBookDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
readonly bookId: UUID;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
providerSeriesId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
providerBookId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
desc: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@IsPositive()
|
||||
volume: number | null;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
provider: string;
|
||||
|
||||
@IsDate()
|
||||
@IsNotEmpty()
|
||||
@Transform(({ value }) => new Date(value))
|
||||
publishedAt: Date
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { UUID } from 'crypto';
|
||||
import { BookOriginType } from 'src/shared/enums/book_origin_type';
|
||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, Unique } from 'typeorm';
|
||||
import { BookEntity } from './book.entity';
|
||||
|
||||
@Entity("book_origins")
|
||||
@Unique(['bookOriginId', 'type', 'value'])
|
||||
export class BookOriginEntity {
|
||||
@PrimaryColumn({ name: 'book_origin_id' })
|
||||
readonly bookOriginId: UUID;
|
||||
|
||||
@Column({ name: 'book_id' })
|
||||
readonly bookId: UUID;
|
||||
|
||||
@Column({ name: 'origin_type' })
|
||||
type: BookOriginType;
|
||||
|
||||
@Column({ name: 'origin_value' })
|
||||
value: string;
|
||||
|
||||
@OneToOne(type => BookEntity, book => book.metadata)
|
||||
@JoinColumn({
|
||||
name: 'book_id',
|
||||
referencedColumnName: 'bookId',
|
||||
})
|
||||
book: BookEntity;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { UUID } from 'crypto';
|
||||
import { UserEntity } from 'src/users/entities/users.entity';
|
||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
|
||||
import { BookEntity } from './book.entity';
|
||||
|
||||
@Entity("book_statuses")
|
||||
export class BookStatusEntity {
|
||||
@PrimaryColumn({ name: 'book_id', type: 'uuid' })
|
||||
readonly bookId: UUID;
|
||||
|
||||
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
|
||||
readonly userId: UUID;
|
||||
|
||||
@Column({ name: 'state', type: 'smallint' })
|
||||
state: number;
|
||||
|
||||
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
|
||||
addedAt: Date
|
||||
|
||||
@Column({ name: 'modified_at', type: 'timestamptz', nullable: false })
|
||||
modifiedAt: Date;
|
||||
|
||||
@OneToOne(type => BookEntity, book => book.statuses)
|
||||
@JoinColumn({
|
||||
name: 'book_id',
|
||||
referencedColumnName: 'bookId',
|
||||
})
|
||||
book: BookEntity;
|
||||
|
||||
@OneToOne(type => UserEntity, user => user.bookStatuses)
|
||||
@JoinColumn({
|
||||
name: 'user_id',
|
||||
referencedColumnName: 'userId',
|
||||
})
|
||||
user: UserEntity;
|
||||
}
|
||||
53
backend/nestjs-seshat-api/src/books/entities/book.entity.ts
Normal file
53
backend/nestjs-seshat-api/src/books/entities/book.entity.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { UUID } from 'crypto';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryColumn, Unique } from 'typeorm';
|
||||
import { BookOriginEntity } from './book-origin.entity';
|
||||
import { BookStatusEntity } from './book-status.entity';
|
||||
import { SeriesEntity } from 'src/series/entities/series.entity';
|
||||
|
||||
@Entity("books")
|
||||
@Unique(['providerSeriesId', 'providerBookId'])
|
||||
export class BookEntity {
|
||||
@PrimaryColumn({ name: 'book_id', type: 'uuid' })
|
||||
readonly bookId: UUID;
|
||||
|
||||
@Column({ name: 'provider_series_id', type: 'text', nullable: true })
|
||||
providerSeriesId: string;
|
||||
|
||||
@Column({ name: 'provider_book_id', type: 'text', nullable: false })
|
||||
providerBookId: string;
|
||||
|
||||
@Column({ name: 'book_title', type: 'text', nullable: false })
|
||||
title: string;
|
||||
|
||||
@Column({ name: 'book_desc', type: 'text', nullable: true })
|
||||
desc: string;
|
||||
|
||||
@Column({ name: 'book_volume', type: 'integer', nullable: true })
|
||||
volume: number;
|
||||
|
||||
@Column({ name: 'provider', type: 'varchar', nullable: false })
|
||||
provider: string;
|
||||
|
||||
@Column({ name: 'published_at', type: 'timestamptz', nullable: false })
|
||||
publishedAt: Date
|
||||
|
||||
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
|
||||
addedAt: Date;
|
||||
|
||||
@OneToMany(type => BookOriginEntity, origin => origin.book)
|
||||
metadata: BookOriginEntity[];
|
||||
|
||||
@OneToMany(type => BookStatusEntity, status => status.book)
|
||||
statuses: BookStatusEntity[];
|
||||
|
||||
@OneToOne(type => SeriesEntity, series => series.volumes)
|
||||
@JoinColumn([{
|
||||
name: 'provider_series_id',
|
||||
referencedColumnName: 'providerSeriesId',
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
referencedColumnName: 'provider',
|
||||
}])
|
||||
series: SeriesEntity;
|
||||
}
|
||||
@@ -12,11 +12,11 @@ export class DatabaseOptions implements TypeOrmOptionsFactory {
|
||||
createTypeOrmOptions(): TypeOrmModuleOptions | Promise<TypeOrmModuleOptions> {
|
||||
return {
|
||||
type: "postgres",
|
||||
host: this.config.getOrThrow('DATABASE_HOST'),
|
||||
port: parseInt(this.config.getOrThrow('DATABASE_PORT'), 10),
|
||||
username: this.config.getOrThrow('DATABASE_USERNAME'),
|
||||
password: this.config.getOrThrow('DATABASE_PASSWORD'),
|
||||
database: this.config.getOrThrow('DATABASE_NAME'),
|
||||
host: this.config.getOrThrow<string>('DATABASE_HOST'),
|
||||
port: parseInt(this.config.getOrThrow<string>('DATABASE_PORT'), 10),
|
||||
username: this.config.getOrThrow<string>('DATABASE_USERNAME'),
|
||||
password: this.config.getOrThrow<string>('DATABASE_PASSWORD'),
|
||||
database: this.config.getOrThrow<string>('DATABASE_NAME'),
|
||||
|
||||
entities: [__dirname + '/../**/*.entity.js'],
|
||||
logging: true,
|
||||
|
||||
201
backend/nestjs-seshat-api/src/library/library.consumer.ts
Normal file
201
backend/nestjs-seshat-api/src/library/library.consumer.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
|
||||
import { OnQueueEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { GoogleSearchContext } from 'src/providers/contexts/google.search.context';
|
||||
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
|
||||
import { ProvidersService } from 'src/providers/providers.service';
|
||||
import { SeriesSubscriptionJobDto } from 'src/series/dto/series-subscription-job.dto';
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
@Processor('library')
|
||||
export class LibraryConsumer extends WorkerHost {
|
||||
constructor(
|
||||
private readonly library: LibraryService,
|
||||
private readonly provider: ProvidersService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job, token?: string): Promise<any> {
|
||||
this.logger.info({
|
||||
class: LibraryConsumer.name,
|
||||
method: this.process.name,
|
||||
job: job,
|
||||
msg: 'Started task on queue.',
|
||||
});
|
||||
|
||||
if (job.name == 'new_series') {
|
||||
const series: SeriesSubscriptionJobDto = job.data;
|
||||
const books = await this.search(job, series, null);
|
||||
|
||||
let counter = 0;
|
||||
for (let book of books) {
|
||||
try {
|
||||
// Force the provider's series id to be set, so that we know which series this belongs.
|
||||
book.result.providerSeriesId = series.providerSeriesId;
|
||||
await this.library.addBook(book.result);
|
||||
} catch (err) {
|
||||
this.logger.error({
|
||||
class: LibraryConsumer.name,
|
||||
method: this.process.name,
|
||||
book: book.result,
|
||||
score: book.score,
|
||||
msg: 'Failed to add book in background during adding series.',
|
||||
error: err,
|
||||
});
|
||||
} finally {
|
||||
counter++;
|
||||
job.updateProgress(25 + 75 * counter / books.length);
|
||||
}
|
||||
}
|
||||
} else if (job.name == 'update_series') {
|
||||
const series: SeriesSubscriptionJobDto = job.data;
|
||||
const existingBooks = await this.library.findBooksFromSeries(series);
|
||||
const existingVolumes = existingBooks.map(b => b.volume);
|
||||
const lastPublishedBook = existingBooks.reduce((a, b) => a.publishedAt.getTime() > b.publishedAt.getTime() ? a : b);
|
||||
const books = await this.search(job, series, lastPublishedBook?.publishedAt);
|
||||
|
||||
let counter = 0;
|
||||
for (let book of books) {
|
||||
if (existingVolumes.includes(book.result.volume)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Force the provider's series id to be set, so that we know which series this belongs.
|
||||
book.result.providerSeriesId = series.providerSeriesId;
|
||||
await this.library.addBook(book.result);
|
||||
} catch (err) {
|
||||
this.logger.error({
|
||||
class: LibraryConsumer.name,
|
||||
method: this.process.name,
|
||||
book: book.result,
|
||||
score: book.score,
|
||||
msg: 'Failed to add book in background during series update.',
|
||||
error: err,
|
||||
});
|
||||
} finally {
|
||||
counter++;
|
||||
job.updateProgress(25 + 75 * counter / books.length);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.warn({
|
||||
class: LibraryConsumer.name,
|
||||
method: this.process.name,
|
||||
job: job,
|
||||
msg: 'Unknown job name found.',
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
class: LibraryConsumer.name,
|
||||
method: this.process.name,
|
||||
job: job,
|
||||
msg: 'Completed task on queue.',
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async search(job: Job, series: SeriesSubscriptionJobDto, after: Date | null): Promise<{ result: BookSearchResultDto, score: number }[]> {
|
||||
let context = this.provider.generateSearchContext(series.provider, series.title) as GoogleSearchContext;
|
||||
context.maxResults = '40';
|
||||
if (after) {
|
||||
context.orderBy = 'newest';
|
||||
}
|
||||
|
||||
// Search for the book(s) via the provider.
|
||||
// Up until end of results or after 3 unhelpful pages of results.
|
||||
let results = [];
|
||||
let related = [];
|
||||
let pageSearchedCount = 0;
|
||||
let unhelpfulResultsCount = 0;
|
||||
do {
|
||||
pageSearchedCount += 1;
|
||||
results = await this.provider.search(context);
|
||||
const potential = results.filter((r: BookSearchResultDto) => r.providerSeriesId == series.providerSeriesId || r.title == series.title && r.mediaType == series.mediaType);
|
||||
if (potential.length > 0) {
|
||||
related.push.apply(related, potential);
|
||||
} else {
|
||||
unhelpfulResultsCount += 1;
|
||||
}
|
||||
context = context.next();
|
||||
job.updateProgress(pageSearchedCount * 5);
|
||||
} while (results.length >= context.maxResults && (!after || after < results[results.length - 1].publishedAt));
|
||||
|
||||
// Sort & de-duplicate the entries received.
|
||||
const books = related.map(book => this.toScore(book, series))
|
||||
.sort((a, b) => a.result.volume - b.result.volume || b.score - a.score)
|
||||
.filter((_, index, arr) => index == 0 || arr[index - 1].result.volume != arr[index].result.volume);
|
||||
job.updateProgress(25);
|
||||
|
||||
this.logger.debug({
|
||||
class: LibraryConsumer.name,
|
||||
method: this.search.name,
|
||||
job: job,
|
||||
msg: 'Finished searching for book entries.',
|
||||
results: {
|
||||
pages: pageSearchedCount,
|
||||
related_entries: related.length,
|
||||
volumes: books.length,
|
||||
}
|
||||
});
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
@OnQueueEvent('failed')
|
||||
onFailed(job: Job, err: Error) {
|
||||
this.logger.error({
|
||||
class: LibraryConsumer.name,
|
||||
method: this.onFailed.name,
|
||||
job: job,
|
||||
msg: 'A library job failed.',
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
|
||||
@OnQueueEvent('paused')
|
||||
onPaused() {
|
||||
this.logger.info({
|
||||
class: LibraryConsumer.name,
|
||||
method: this.onPaused.name,
|
||||
msg: 'Library jobs have been paused.',
|
||||
});
|
||||
}
|
||||
|
||||
@OnQueueEvent('resumed')
|
||||
onResume(job: Job) {
|
||||
this.logger.info({
|
||||
class: LibraryConsumer.name,
|
||||
method: this.onResume.name,
|
||||
msg: 'Library jobs have resumed.',
|
||||
});
|
||||
}
|
||||
|
||||
@OnQueueEvent('waiting')
|
||||
onWaiting(jobId: number | string) {
|
||||
this.logger.info({
|
||||
class: LibraryConsumer.name,
|
||||
method: this.onWaiting.name,
|
||||
msg: 'A library job is waiting...',
|
||||
});
|
||||
}
|
||||
|
||||
private toScore(book: BookSearchResultDto, series: SeriesSubscriptionJobDto): ({ result: BookSearchResultDto, score: number }) {
|
||||
if (!book) {
|
||||
return {
|
||||
result: null,
|
||||
score: -1,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: book,
|
||||
score: (!!book.providerSeriesId ? 50 : 0) + (book.title == series.title ? 25 : 0) + (book.url.startsWith('https://play.google.com/store/books/details?') ? 10 : 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { LibraryController } from './library.controller';
|
||||
|
||||
describe('LibraryController', () => {
|
||||
let controller: LibraryController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [LibraryController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<LibraryController>(LibraryController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
477
backend/nestjs-seshat-api/src/library/library.controller.ts
Normal file
477
backend/nestjs-seshat-api/src/library/library.controller.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Body, Controller, Delete, Get, Patch, Post, Put, Request, Res, UseGuards } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Queue } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { BooksService } from 'src/books/books.service';
|
||||
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
|
||||
import { SeriesService } from 'src/series/series.service';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { UpdateBookDto } from 'src/books/dto/update-book.dto';
|
||||
import { UpdateBookOriginDto } from 'src/books/dto/update-book-origin.dto';
|
||||
import { LibraryService } from './library.service';
|
||||
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
|
||||
import { SeriesDto } from 'src/series/dto/series.dto';
|
||||
import { DeleteBookStatusDto } from 'src/books/dto/delete-book-status.dto';
|
||||
import { CreateBookStatusDto } from 'src/books/dto/create-book-status.dto';
|
||||
import { JwtAccessAdminGuard } from 'src/auth/guards/jwt-access.admin.guard';
|
||||
import { BookOriginDto } from 'src/books/dto/book-origin.dto';
|
||||
import { CreateSeriesDto } from 'src/series/dto/create-series.dto';
|
||||
|
||||
@UseGuards(JwtAccessGuard)
|
||||
@Controller('library')
|
||||
export class LibraryController {
|
||||
constructor(
|
||||
private readonly books: BooksService,
|
||||
private readonly series: SeriesService,
|
||||
private readonly library: LibraryService,
|
||||
@InjectQueue('library') private readonly jobs: Queue,
|
||||
private readonly logger: PinoLogger,
|
||||
) { }
|
||||
|
||||
@Get('series')
|
||||
async getSeries(
|
||||
@Request() req,
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.series.getAllSeries(),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('series')
|
||||
async createSeries(
|
||||
@Request() req,
|
||||
@Body() body: CreateSeriesDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
try {
|
||||
await this.library.addSeries(body);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof QueryFailedError) {
|
||||
if (err.driverError.code == '23505') {
|
||||
this.logger.warn({
|
||||
class: LibraryController.name,
|
||||
method: this.createSeries.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to create a series.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
response.statusCode = 409;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Series already exists.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error({
|
||||
class: LibraryController.name,
|
||||
method: this.createSeries.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to create a series.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Patch('series')
|
||||
async updateSeries(
|
||||
@Request() req,
|
||||
@Body() body: CreateSeriesDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
try {
|
||||
await this.library.updateSeries(body);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof QueryFailedError) {
|
||||
if (err.driverError.code == '23505') {
|
||||
this.logger.warn({
|
||||
class: LibraryController.name,
|
||||
method: this.updateSeries.name,
|
||||
user: req.user,
|
||||
msg: 'Failed to update a series.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
// Subscription already exist.
|
||||
response.statusCode = 409;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Series subscription already exists.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error({
|
||||
class: LibraryController.name,
|
||||
method: this.updateSeries.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to update a series.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAccessAdminGuard)
|
||||
@Delete('series')
|
||||
async deleteSeries(
|
||||
@Request() req,
|
||||
@Body() body: SeriesDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
try {
|
||||
const del = await this.series.deleteSeries(body);
|
||||
return {
|
||||
success: del && del.affected > 0,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof QueryFailedError) {
|
||||
if (err.driverError.code == '23503') {
|
||||
this.logger.warn({
|
||||
class: LibraryController.name,
|
||||
method: this.deleteSeries.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to delete a series.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
response.statusCode = 404;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'The series does not exist.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error({
|
||||
class: LibraryController.name,
|
||||
method: this.deleteSeries.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to delete a series.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Get('series/subscriptions')
|
||||
async getSeriesSubscriptions(
|
||||
@Request() req,
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.series.getSeriesSubscribedBy(req.user.userId),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('series/subscribe')
|
||||
async subscribe(
|
||||
@Request() req,
|
||||
@Body() body: SeriesDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
try {
|
||||
await this.library.addSubscription({
|
||||
...body,
|
||||
userId: req.user.userId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof QueryFailedError) {
|
||||
if (err.driverError.code == '23505') {
|
||||
this.logger.warn({
|
||||
class: LibraryController.name,
|
||||
method: this.subscribe.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to subscribe to a series.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
// Subscription already exists.
|
||||
response.statusCode = 409;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Series subscription already exists.',
|
||||
};
|
||||
} else if (err.driverError.code == '23503') {
|
||||
this.logger.warn({
|
||||
class: LibraryController.name,
|
||||
method: this.subscribe.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to subscribe to a series.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
// Series does not exist.
|
||||
response.statusCode = 404;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Series does not exist.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error({
|
||||
class: LibraryController.name,
|
||||
method: this.subscribe.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to subscribe to a series.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('series/subscribe')
|
||||
async deleteSeriesSubscription(
|
||||
@Request() req,
|
||||
@Body() body: SeriesDto,
|
||||
) {
|
||||
const del = await this.series.deleteSeriesSubscription({
|
||||
...body,
|
||||
userId: req.user.userId,
|
||||
});
|
||||
return {
|
||||
success: del && del.affected > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('books')
|
||||
async getBooksFromUser(
|
||||
@Request() req,
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.library.findBooks(),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('books')
|
||||
async createBook(
|
||||
@Request() req,
|
||||
@Body() body: BookSearchResultDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
if (body.provider && body.providerSeriesId) {
|
||||
this.logger.warn({
|
||||
class: LibraryController.name,
|
||||
method: this.createBook.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to create book due to book being part of a series.',
|
||||
});
|
||||
|
||||
response.statusCode = 400;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'This book is part of a seris. Use the series route to create a series.',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.books.createBook(body),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof QueryFailedError) {
|
||||
if (err.driverError.code == '23505') {
|
||||
this.logger.warn({
|
||||
class: LibraryController.name,
|
||||
method: this.createBook.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to create book.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
// Book exists already.
|
||||
response.statusCode = 409;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'The book has already been added previously.',
|
||||
};
|
||||
} else if (err.driverError.code == '23503') {
|
||||
this.logger.warn({
|
||||
class: LibraryController.name,
|
||||
method: this.createBook.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to create book.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
// Series is missing.
|
||||
response.statusCode = 404;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Series does not exist.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error({
|
||||
class: LibraryController.name,
|
||||
method: this.createBook.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to create a book.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong while adding the book.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Put('books')
|
||||
async updateBook(
|
||||
@Body() body: UpdateBookDto,
|
||||
) {
|
||||
const data = { ...body };
|
||||
delete data['bookId'];
|
||||
|
||||
const result = await this.books.updateBook(body.bookId, data);
|
||||
return {
|
||||
success: result && result.affected > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete('books/origins')
|
||||
async deleteBookOrigin(
|
||||
@Body() body: BookOriginDto[],
|
||||
) {
|
||||
const result = await this.books.deleteBookOrigin(body);
|
||||
return {
|
||||
success: result && result.affected > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@Put('books/origins')
|
||||
async updateBookOrigin(
|
||||
@Body() body: UpdateBookOriginDto,
|
||||
) {
|
||||
const data = { ...body };
|
||||
delete data['bookOriginId'];
|
||||
|
||||
const result = await this.books.updateBookOrigin(body.bookOriginId, data);
|
||||
return {
|
||||
success: result && result.affected > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('books/status')
|
||||
async getBookStatus(
|
||||
@Request() req,
|
||||
@Body() body: SeriesDto,
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.books.findActualBookStatusesTrackedBy(req.user.userId, body),
|
||||
};
|
||||
}
|
||||
|
||||
@Put('books/status')
|
||||
async updateBookStatus(
|
||||
@Request() req,
|
||||
@Body() body: CreateBookStatusDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
try {
|
||||
await this.books.updateBookStatus(body);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof QueryFailedError) {
|
||||
if (err.driverError.code == '23503') {
|
||||
this.logger.warn({
|
||||
class: LibraryController.name,
|
||||
method: this.updateBookStatus.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to update the user\'s status of a book.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
response.statusCode = 404;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'The book does not exist.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error({
|
||||
class: LibraryController.name,
|
||||
method: this.updateBookStatus.name,
|
||||
user: req.user,
|
||||
body: body,
|
||||
msg: 'Failed to update the user\'s status of a book.',
|
||||
error: err,
|
||||
});
|
||||
|
||||
response.statusCode = 500;
|
||||
return {
|
||||
success: false,
|
||||
error_message: 'Something went wrong.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('books/status')
|
||||
async deleteBookStatus(
|
||||
@Body() body: DeleteBookStatusDto,
|
||||
) {
|
||||
const result = await this.books.deleteBookStatus(body);
|
||||
return {
|
||||
success: result && result.affected > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
38
backend/nestjs-seshat-api/src/library/library.module.ts
Normal file
38
backend/nestjs-seshat-api/src/library/library.module.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LibraryService } from './library.service';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BookOriginEntity } from 'src/books/entities/book-origin.entity';
|
||||
import { BookStatusEntity } from 'src/books/entities/book-status.entity';
|
||||
import { BookEntity } from 'src/books/entities/book.entity';
|
||||
import { ProvidersModule } from 'src/providers/providers.module';
|
||||
import { SeriesModule } from 'src/series/series.module';
|
||||
import { SeriesEntity } from 'src/series/entities/series.entity';
|
||||
import { SeriesSubscriptionEntity } from 'src/series/entities/series-subscription.entity';
|
||||
import { BooksService } from 'src/books/books.service';
|
||||
import { SeriesService } from 'src/series/series.service';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { LibraryConsumer } from './library.consumer';
|
||||
import { LibraryController } from './library.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
BookEntity,
|
||||
BookOriginEntity,
|
||||
BookStatusEntity,
|
||||
SeriesEntity,
|
||||
SeriesSubscriptionEntity,
|
||||
]),
|
||||
BullModule.registerQueue({
|
||||
name: 'library',
|
||||
}),
|
||||
SeriesModule,
|
||||
HttpModule,
|
||||
ProvidersModule,
|
||||
],
|
||||
exports: [LibraryService],
|
||||
providers: [LibraryService, BooksService, SeriesService, LibraryConsumer],
|
||||
controllers: [LibraryController]
|
||||
})
|
||||
export class LibraryModule { }
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
describe('LibraryService', () => {
|
||||
let service: LibraryService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [LibraryService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LibraryService>(LibraryService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
150
backend/nestjs-seshat-api/src/library/library.service.ts
Normal file
150
backend/nestjs-seshat-api/src/library/library.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { BooksService } from 'src/books/books.service';
|
||||
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
|
||||
import { CreateSeriesDto } from 'src/series/dto/create-series.dto';
|
||||
import { SeriesSubscriptionDto } from 'src/series/dto/series-subscription.dto';
|
||||
import { SeriesDto } from 'src/series/dto/series.dto';
|
||||
import { SeriesService } from 'src/series/series.service';
|
||||
import { BookOriginType } from 'src/shared/enums/book_origin_type';
|
||||
|
||||
@Injectable()
|
||||
export class LibraryService {
|
||||
constructor(
|
||||
private readonly books: BooksService,
|
||||
private readonly series: SeriesService,
|
||||
@InjectQueue('library') private readonly jobs: Queue,
|
||||
private readonly logger: PinoLogger,
|
||||
) { }
|
||||
|
||||
|
||||
async addSeries(series: CreateSeriesDto) {
|
||||
const result = await this.series.addSeries(series);
|
||||
|
||||
this.logger.debug({
|
||||
class: LibraryService.name,
|
||||
method: this.addSubscription.name,
|
||||
series: series,
|
||||
msg: 'Series saved to database.',
|
||||
});
|
||||
|
||||
this.jobs.add('new_series', series);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
async addSubscription(series: SeriesSubscriptionDto) {
|
||||
return await this.series.addSeriesSubscription({
|
||||
userId: series.userId,
|
||||
providerSeriesId: series.providerSeriesId,
|
||||
provider: series.provider,
|
||||
});
|
||||
}
|
||||
|
||||
async addBook(book: BookSearchResultDto) {
|
||||
this.logger.debug({
|
||||
class: LibraryService.name,
|
||||
method: this.addBook.name,
|
||||
book: book,
|
||||
msg: 'Saving book to database...',
|
||||
});
|
||||
|
||||
const bookData = await this.books.createBook({
|
||||
title: book.title,
|
||||
desc: book.desc,
|
||||
providerSeriesId: book.providerSeriesId,
|
||||
providerBookId: book.providerBookId,
|
||||
volume: book.volume,
|
||||
provider: book.provider,
|
||||
publishedAt: book.publishedAt,
|
||||
});
|
||||
const bookId = bookData.identifiers[0]['bookId'];
|
||||
|
||||
const tasks = [];
|
||||
|
||||
if (book.authors && book.authors.length > 0) {
|
||||
tasks.push(book.authors.map(author => this.books.addBookOrigin({
|
||||
bookId,
|
||||
type: BookOriginType.AUTHOR,
|
||||
value: author,
|
||||
})));
|
||||
}
|
||||
if (book.categories && book.categories.length > 0) {
|
||||
tasks.push(book.categories.map(category => this.books.addBookOrigin({
|
||||
bookId,
|
||||
type: BookOriginType.CATEGORY,
|
||||
value: category
|
||||
})));
|
||||
}
|
||||
if (book.language) {
|
||||
tasks.push(this.books.addBookOrigin({
|
||||
bookId,
|
||||
type: BookOriginType.LANGUAGE,
|
||||
value: book.language,
|
||||
}));
|
||||
}
|
||||
if (book.maturityRating) {
|
||||
tasks.push(this.books.addBookOrigin({
|
||||
bookId,
|
||||
type: BookOriginType.MATURITY_RATING,
|
||||
value: book.maturityRating,
|
||||
}));
|
||||
}
|
||||
if (book.thumbnail) {
|
||||
tasks.push(this.books.addBookOrigin({
|
||||
bookId,
|
||||
type: BookOriginType.PROVIDER_THUMBNAIL,
|
||||
value: book.thumbnail,
|
||||
}));
|
||||
}
|
||||
if (book.url) {
|
||||
tasks.push(this.books.addBookOrigin({
|
||||
bookId,
|
||||
type: BookOriginType.PROVIDER_URL,
|
||||
value: book.url,
|
||||
}));
|
||||
}
|
||||
|
||||
if ('ISBN_10' in book.industryIdentifiers) {
|
||||
tasks.push(this.books.addBookOrigin({
|
||||
bookId,
|
||||
type: BookOriginType.ISBN_10,
|
||||
value: book.industryIdentifiers['ISBN_10'],
|
||||
}));
|
||||
}
|
||||
|
||||
if ('ISBN_13' in book.industryIdentifiers) {
|
||||
tasks.push(this.books.addBookOrigin({
|
||||
bookId,
|
||||
type: BookOriginType.ISBN_10,
|
||||
value: book.industryIdentifiers['ISBN_13'],
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
|
||||
this.logger.info({
|
||||
class: LibraryService.name,
|
||||
method: this.addBook.name,
|
||||
book: book,
|
||||
msg: 'Book saved to database.',
|
||||
});
|
||||
|
||||
return bookId;
|
||||
}
|
||||
|
||||
async findBooks() {
|
||||
return await this.books.findBooks();
|
||||
}
|
||||
|
||||
async findBooksFromSeries(series: SeriesDto) {
|
||||
return await this.books.findBooksFromSeries(series);
|
||||
}
|
||||
|
||||
async updateSeries(series: CreateSeriesDto) {
|
||||
return await this.jobs.add('update_series', series);
|
||||
}
|
||||
}
|
||||
94
backend/nestjs-seshat-api/src/logging.serializers.ts
Normal file
94
backend/nestjs-seshat-api/src/logging.serializers.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { UserEntity } from "./users/entities/users.entity";
|
||||
|
||||
export function serialize_user_short(value: UserEntity) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.userLogin;
|
||||
}
|
||||
|
||||
export function serialize_user_long(value: UserEntity) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return {
|
||||
user_id: value.userId,
|
||||
user_login: value.userLogin,
|
||||
is_admin: value.isAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
export function serialize_token(value: string) {
|
||||
if (!value) return null;
|
||||
return '...' + value.substring(Math.max(value.length - 12, value.length / 2) | 0);
|
||||
}
|
||||
|
||||
export function serialize_req(value) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
value = { ...value };
|
||||
|
||||
delete value['remoteAddress']
|
||||
delete value['remotePort']
|
||||
|
||||
if (value.headers) {
|
||||
const headers = value.headers = { ...value.headers };
|
||||
if (headers['authorization']) {
|
||||
headers['authorization'] = '...' + headers['authorization'].substring(Math.max(0, headers['authorization'].length - 16))
|
||||
}
|
||||
|
||||
if (headers['cookie']) {
|
||||
const cookies = headers['cookie'].split(';')
|
||||
.map((c: string) => {
|
||||
c = c.trim();
|
||||
const index = c.indexOf('=');
|
||||
if (index < 0)
|
||||
return c;
|
||||
|
||||
const cookieValue = c.substring(index + 1);
|
||||
return c.substring(0, index) + '=...' + cookieValue.substring(Math.max(cookieValue.length - 16, cookieValue.length / 2));
|
||||
});
|
||||
headers['cookie'] = cookies.join('; ');
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function serialize_res(value) {
|
||||
if (!value || !value.headers) {
|
||||
return value;
|
||||
}
|
||||
|
||||
value = { ...value };
|
||||
const headers = value.headers = { ...value.headers };
|
||||
delete headers['x-powered-by'];
|
||||
|
||||
if (headers['set-cookie']) {
|
||||
const cookies = headers['set-cookie'];
|
||||
for (let i in cookies) {
|
||||
const cookie: string = cookies[i];
|
||||
if (cookie.startsWith('Authentication=')) {
|
||||
cookies[i] = 'Authentication=...' + cookie.substring(Math.max(0, cookie.indexOf(';') - 12));
|
||||
} else if (cookie.startsWith('Refresh=')) {
|
||||
cookies[i] = 'Refresh=...' + cookie.substring(Math.max(0, cookie.indexOf(';') - 12));
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function serialize_job(value) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
data: value.data,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { Logger, LoggerErrorInterceptor } from 'nestjs-pino';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
app.use(cookieParser());
|
||||
await app.listen(process.env.PORT ?? 3001);
|
||||
app.useGlobalPipes(new ValidationPipe({
|
||||
stopAtFirstError: true,
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
}));
|
||||
app.useLogger(app.get(Logger));
|
||||
app.useGlobalInterceptors(new LoggerErrorInterceptor());
|
||||
await app.listen(process.env.WEB_API_PORT ?? 3001);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { SearchContext } from "./search.context";
|
||||
|
||||
export class GoogleSearchContext extends SearchContext {
|
||||
constructor(searchQuery: string, params: { [key: string]: string }) {
|
||||
super('google', searchQuery, params);
|
||||
}
|
||||
|
||||
|
||||
generateQueryParams(): string {
|
||||
const filterParams = ['maxResults', 'startIndex', 'orderBy', 'langRestrict'];
|
||||
const searchParams = ['intitle', 'inauthor', 'inpublisher', 'subject', 'isbn'];
|
||||
|
||||
const queryParams = filterParams
|
||||
.map(p => p in this.params ? p + '=' + this.params[p] : undefined)
|
||||
.filter(p => p !== undefined)
|
||||
.join('&');
|
||||
|
||||
const search = this.search.trim();
|
||||
const searchQueryParam = [
|
||||
search.length > 0 ? search + ' ' : '',
|
||||
...searchParams.map(p => this.params[p] ? p + ':"' + this.params[p] + '"' : ''),
|
||||
].filter(p => p.length > 0).join('');
|
||||
|
||||
return [queryParams, 'q=' + searchQueryParam].filter(q => q.length > 2).join('&');
|
||||
}
|
||||
|
||||
get orderBy(): 'newest' | 'relevant' {
|
||||
return this.params['orderBy'] as 'newest' | 'relevant' ?? 'relevant';
|
||||
}
|
||||
|
||||
set orderBy(value: 'newest' | 'relevant' | null) {
|
||||
if (!value) {
|
||||
delete this.params['orderBy'];
|
||||
} else {
|
||||
this.params['orderBy'] = value;
|
||||
}
|
||||
}
|
||||
|
||||
get maxResults(): number {
|
||||
return 'maxResults' in this.params ? parseInt(this.params['maxResults']) : 10;
|
||||
}
|
||||
|
||||
set maxResults(value: string) {
|
||||
if (!value || isNaN(parseInt(value))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.params['maxResults'] = value;
|
||||
}
|
||||
|
||||
get startIndex(): number {
|
||||
return 'startIndex' in this.params ? parseInt(this.params['startIndex']) : 10;
|
||||
}
|
||||
|
||||
set startIndex(value: string) {
|
||||
if (!value || isNaN(parseInt(value))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.params['startIndex'] = value;
|
||||
}
|
||||
|
||||
get intitle(): string {
|
||||
return 'intitle' in this.params ? this.params['intitle'] : null;
|
||||
}
|
||||
|
||||
set intitle(value: string) {
|
||||
if (!value) {
|
||||
delete this.params['intitle'];
|
||||
} else {
|
||||
this.params['intitle'] = value;
|
||||
}
|
||||
}
|
||||
|
||||
get inpublisher(): string {
|
||||
return 'inpublisher' in this.params ? this.params['inpublisher'] : null;
|
||||
}
|
||||
|
||||
set inpublisher(value: string) {
|
||||
if (!value) {
|
||||
delete this.params['inpublisher'];
|
||||
} else {
|
||||
this.params['inpublisher'] = value;
|
||||
}
|
||||
}
|
||||
|
||||
get inauthor(): string {
|
||||
return 'inauthor' in this.params ? this.params['inauthor'] : null;
|
||||
}
|
||||
|
||||
set inauthor(value: string) {
|
||||
if (!value) {
|
||||
delete this.params['inauthor'];
|
||||
} else {
|
||||
this.params['inauthor'] = value;
|
||||
}
|
||||
}
|
||||
|
||||
get isbn(): string {
|
||||
return 'isbn' in this.params ? this.params['isbn'] : null;
|
||||
}
|
||||
|
||||
set isbn(value: string) {
|
||||
if (!value) {
|
||||
delete this.params['isbn'];
|
||||
} else {
|
||||
this.params['isbn'] = value;
|
||||
}
|
||||
}
|
||||
|
||||
get subject(): string {
|
||||
return 'subject' in this.params ? this.params['subject'] : null;
|
||||
}
|
||||
|
||||
set subject(value: string) {
|
||||
if (!value) {
|
||||
delete this.params['subject'];
|
||||
} else {
|
||||
this.params['subject'] = value;
|
||||
}
|
||||
}
|
||||
|
||||
previous(pageCount: number = 1): GoogleSearchContext {
|
||||
if (pageCount > 0)
|
||||
return this.update(-pageCount);
|
||||
return this;
|
||||
}
|
||||
|
||||
next(pageCount: number = 1): GoogleSearchContext {
|
||||
if (pageCount > 0)
|
||||
return this.update(pageCount);
|
||||
return this;
|
||||
}
|
||||
|
||||
private update(pageChange: number): GoogleSearchContext {
|
||||
const resultsPerPage = this.params['maxResults'] ? parseInt(this.params['maxResults']) : 10;
|
||||
const index = this.params['startIndex'] ? parseInt(this.params['startIndex']) : 0;
|
||||
|
||||
const data = { ...this.params };
|
||||
data['startIndex'] = Math.max(0, index + resultsPerPage * pageChange).toString();
|
||||
|
||||
return new GoogleSearchContext(this.search, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export abstract class SearchContext {
|
||||
provider: string;
|
||||
search: string;
|
||||
params: { [key: string]: string };
|
||||
|
||||
constructor(provider: string, search: string, params: { [key: string]: string }) {
|
||||
this.provider = provider;
|
||||
this.search = search;
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
abstract generateQueryParams(): string;
|
||||
abstract previous(pageCount: number): SearchContext;
|
||||
abstract next(pageCount: number): SearchContext;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { GoogleSearchContext } from "./google.search.context";
|
||||
import { SearchContext } from "./search.context";
|
||||
|
||||
export class SimplifiedSearchContext {
|
||||
values: { [key: string]: string };
|
||||
|
||||
constructor(values: { [key: string]: string }) {
|
||||
this.values = values;
|
||||
}
|
||||
|
||||
toSearchContext(): SearchContext | null {
|
||||
const provider = this.values['provider']?.toString().toLowerCase();
|
||||
const search = this.values['search']?.toString();
|
||||
const valuesCopy = { ...this.values };
|
||||
delete valuesCopy['provider'];
|
||||
delete valuesCopy['search'];
|
||||
|
||||
if (provider == 'google') {
|
||||
return new GoogleSearchContext(search, valuesCopy)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsAlpha, IsIn, IsNotEmpty, IsString, Length } from "class-validator";
|
||||
|
||||
export class BookSearchInputDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsAlpha()
|
||||
@IsIn(['google'])
|
||||
@Transform(({ value }) => value.toLowerCase())
|
||||
provider!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(1, 64)
|
||||
query!: string;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsArray, IsDate, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
export class BookSearchResultDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
providerBookId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
providerSeriesId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
desc: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
volume: number|null;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
publisher: string;
|
||||
|
||||
@IsArray()
|
||||
@IsNotEmpty()
|
||||
@IsString({ each: true })
|
||||
authors: string[];
|
||||
|
||||
@IsObject()
|
||||
@IsNotEmpty()
|
||||
industryIdentifiers: { [key: string]: string };
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
provider: string;
|
||||
|
||||
@IsDate()
|
||||
@IsNotEmpty()
|
||||
@Transform(({ value }) => new Date(value))
|
||||
publishedAt: Date;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
language: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
mediaType: string | null;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
categories: string[];
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
maturityRating: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
thumbnail: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
url: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { GoogleService } from './google.service';
|
||||
|
||||
describe('GoogleService', () => {
|
||||
let service: GoogleService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [GoogleService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<GoogleService>(GoogleService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
138
backend/nestjs-seshat-api/src/providers/google/google.service.ts
Normal file
138
backend/nestjs-seshat-api/src/providers/google/google.service.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { BadRequestException, HttpException, Injectable } from '@nestjs/common';
|
||||
import { BookSearchResultDto } from '../dto/book-search-result.dto';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { catchError, EMPTY, firstValueFrom, map, timeout } from 'rxjs';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { GoogleSearchContext } from '../contexts/google.search.context';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleService {
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
private readonly logger: PinoLogger,
|
||||
) { }
|
||||
|
||||
async searchRaw(searchQuery: string): Promise<BookSearchResultDto[]> {
|
||||
const queryParams = 'langRestrict=en&printType=books&maxResults=20&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))&q=';
|
||||
|
||||
return await firstValueFrom(
|
||||
this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery)
|
||||
.pipe(
|
||||
timeout({ first: 5000 }),
|
||||
map(value => this.transform(value)),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async search(context: GoogleSearchContext): Promise<BookSearchResultDto[]> {
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultQueryParams = 'printType=books&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))';
|
||||
const customQueryParams = context.generateQueryParams();
|
||||
|
||||
return await firstValueFrom(
|
||||
this.http.get('https://www.googleapis.com/books/v1/volumes?' + defaultQueryParams + '&' + customQueryParams)
|
||||
.pipe(
|
||||
timeout({ first: 5000 }),
|
||||
map(value => this.transform(value)),
|
||||
catchError((err: any) => {
|
||||
if (err instanceof AxiosError) {
|
||||
if (err.status == 400) {
|
||||
throw new BadRequestException(err.response.data);
|
||||
} else if (err.status == 429) {
|
||||
throw new HttpException(err.response?.data, 429);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error({
|
||||
class: GoogleService.name,
|
||||
method: this.search.name,
|
||||
msg: 'Unknown Google search error.',
|
||||
error: err,
|
||||
});
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private transform(response: AxiosResponse): BookSearchResultDto[] {
|
||||
if (!response.data?.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.items
|
||||
.map(item => this.extract(item));
|
||||
}
|
||||
|
||||
private extract(item: any): BookSearchResultDto {
|
||||
const secure = process.env.WEB_SECURE?.toLowerCase() == 'true';
|
||||
const result: BookSearchResultDto = {
|
||||
providerBookId: item.id,
|
||||
providerSeriesId: item.volumeInfo.seriesInfo?.volumeSeries[0].seriesId,
|
||||
title: item.volumeInfo.title,
|
||||
desc: item.volumeInfo.description,
|
||||
volume: item.volumeInfo.seriesInfo?.bookDisplayNumber ? parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber, 10) : undefined,
|
||||
publisher: item.volumeInfo.publisher,
|
||||
authors: item.volumeInfo.authors,
|
||||
categories: item.volumeInfo.categories ?? [],
|
||||
mediaType: null,
|
||||
maturityRating: item.volumeInfo.maturityRating,
|
||||
industryIdentifiers: item.volumeInfo.industryIdentifiers ? Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => i.type == 'OTHER' ? { [i.identifier.split(':')[0]]: i.identifier.split(':')[1] } : { [i.type]: i.identifier })) : [],
|
||||
publishedAt: new Date(item.volumeInfo.publishedDate),
|
||||
language: item.volumeInfo.language,
|
||||
thumbnail: secure && item.volumeInfo.imageLinks?.thumbnail ? item.volumeInfo.imageLinks.thumbnail.replaceAll('http://', 'https://') : item.volumeInfo.imageLinks?.thumbnail,
|
||||
url: item.volumeInfo.canonicalVolumeLink,
|
||||
provider: 'google'
|
||||
};
|
||||
|
||||
const regex = this.getRegexByPublisher(result.publisher);
|
||||
const match = result.title.match(regex);
|
||||
if (match?.groups) {
|
||||
result.title = match.groups['title'].trim();
|
||||
if (!result.volume || isNaN(result.volume)) {
|
||||
result.volume = parseInt(match.groups['volume'], 10);
|
||||
}
|
||||
}
|
||||
|
||||
if (match?.groups && 'media_type' in match.groups) {
|
||||
result.mediaType = match.groups['media_type'];
|
||||
} else if (result.categories.includes('Comics & Graphic Novels')) {
|
||||
result.mediaType = 'Comics & Graphic Novels';
|
||||
} else if (result.categories.includes('Fiction') || result.categories.includes('Young Adult Fiction')) {
|
||||
result.mediaType = 'Novel';
|
||||
} else {
|
||||
result.mediaType = 'Book';
|
||||
}
|
||||
|
||||
if (result.mediaType) {
|
||||
if (result.mediaType.toLowerCase() == "light novel") {
|
||||
result.mediaType = 'Light Novel';
|
||||
} else if (result.mediaType.toLowerCase() == 'manga') {
|
||||
result.mediaType = 'Manga';
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private getRegexByPublisher(publisher: string): RegExp {
|
||||
switch (publisher) {
|
||||
case 'J-Novel Club':
|
||||
return /^(?<title>.+?):?(?:\s\((?<media_type>\w+)\))?(?:\sVolume\s(?<volume>\d+))?$/i;
|
||||
case 'Yen On':
|
||||
case 'Yen Press':
|
||||
case 'Yen Press LLC':
|
||||
return /^(?:(?<title>.+?)(?:,?\sVol\.?\s(?<volume>\d+))(?:\s\((?<media_type>[\w\s]+)\))?)$/i;
|
||||
case 'Hanashi Media':
|
||||
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\),?\sVol\.\s(?<volume>\d+)$/i
|
||||
case 'Regin\'s Chronicles':
|
||||
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\)(?<subtitle>\:\s.+?)?$/i
|
||||
default:
|
||||
return /^(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)$/i;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ProvidersController } from './providers.controller';
|
||||
|
||||
describe('ProvidersController', () => {
|
||||
let controller: ProvidersController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ProvidersController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<ProvidersController>(ProvidersController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ProvidersService } from './providers.service';
|
||||
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
|
||||
import { SimplifiedSearchContext } from './contexts/simplified-search-context';
|
||||
|
||||
@Controller('providers')
|
||||
export class ProvidersController {
|
||||
constructor(
|
||||
private providers: ProvidersService,
|
||||
) { }
|
||||
|
||||
@UseGuards(JwtAccessGuard)
|
||||
@Get('search')
|
||||
async Search(
|
||||
@Query() context,
|
||||
) {
|
||||
const simplified = new SimplifiedSearchContext(context);
|
||||
const searchContext = simplified.toSearchContext();
|
||||
return await this.providers.search(searchContext);
|
||||
}
|
||||
}
|
||||
27
backend/nestjs-seshat-api/src/providers/providers.module.ts
Normal file
27
backend/nestjs-seshat-api/src/providers/providers.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoogleService } from './google/google.service';
|
||||
import { ProvidersService } from './providers.service';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ProvidersController } from './providers.controller';
|
||||
import { BooksService } from 'src/books/books.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (config: ConfigService) => ({
|
||||
timeout: config.get('HTTP_TIMEOUT') ?? 5000,
|
||||
maxRedirects: config.get('HTTP_MAX_REDIRECTS') ?? 5,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [
|
||||
ProvidersService,
|
||||
GoogleService,
|
||||
],
|
||||
providers: [GoogleService, ProvidersService],
|
||||
controllers: [ProvidersController]
|
||||
})
|
||||
export class ProvidersModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ProvidersService } from './providers.service';
|
||||
|
||||
describe('ProvidersService', () => {
|
||||
let service: ProvidersService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ProvidersService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ProvidersService>(ProvidersService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
38
backend/nestjs-seshat-api/src/providers/providers.service.ts
Normal file
38
backend/nestjs-seshat-api/src/providers/providers.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { GoogleService } from './google/google.service';
|
||||
import { BookSearchResultDto } from './dto/book-search-result.dto';
|
||||
import { GoogleSearchContext } from './contexts/google.search.context';
|
||||
import { SearchContext } from './contexts/search.context';
|
||||
|
||||
@Injectable()
|
||||
export class ProvidersService {
|
||||
constructor(
|
||||
private readonly google: GoogleService,
|
||||
) { }
|
||||
|
||||
generateSearchContext(providerName: string, searchQuery: string): SearchContext | null {
|
||||
let params: { [key: string]: string } = {};
|
||||
if (providerName.toLowerCase() == 'google') {
|
||||
return new GoogleSearchContext(searchQuery, params);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async searchRaw(providerName: string, searchQuery: string): Promise<BookSearchResultDto[]> {
|
||||
switch (providerName.toLowerCase()) {
|
||||
case 'google':
|
||||
return await this.google.searchRaw(searchQuery);
|
||||
default:
|
||||
throw Error('Invalid provider name.');
|
||||
}
|
||||
}
|
||||
|
||||
async search(context: SearchContext): Promise<BookSearchResultDto[]> {
|
||||
switch (context.provider.toLowerCase()) {
|
||||
case 'google':
|
||||
return await this.google.search(context as GoogleSearchContext);
|
||||
default:
|
||||
throw Error('Invalid provider name.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { SeriesDto } from './series.dto';
|
||||
|
||||
export class CreateSeriesDto extends SeriesDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
mediaType: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { SeriesSubscriptionDto } from './series-subscription.dto';
|
||||
|
||||
export class SeriesSubscriptionJobDto extends SeriesSubscriptionDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
mediaType: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
import { SeriesDto } from './series.dto';
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
export class SeriesSubscriptionDto extends SeriesDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
userId: UUID;
|
||||
}
|
||||
11
backend/nestjs-seshat-api/src/series/dto/series.dto.ts
Normal file
11
backend/nestjs-seshat-api/src/series/dto/series.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class SeriesDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
providerSeriesId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
provider: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
import { UUID } from 'crypto';
|
||||
import { CreateSeriesDto } from './create-series.dto';
|
||||
|
||||
export class UpdateSeriesDto extends CreateSeriesDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
seriesId: UUID;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { UUID } from 'crypto';
|
||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, Unique } from 'typeorm';
|
||||
import { SeriesEntity } from './series.entity';
|
||||
|
||||
@Entity("series_subscriptions")
|
||||
export class SeriesSubscriptionEntity {
|
||||
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
|
||||
readonly userId: UUID;
|
||||
|
||||
@PrimaryColumn({ name: 'provider_series_id', type: 'text' })
|
||||
providerSeriesId: string;
|
||||
|
||||
@PrimaryColumn({ name: 'provider', type: 'text' })
|
||||
provider: string;
|
||||
|
||||
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
|
||||
addedAt: Date;
|
||||
|
||||
@OneToOne(type => SeriesEntity, series => series.subscriptions)
|
||||
@JoinColumn([{
|
||||
name: 'provider_series_id',
|
||||
referencedColumnName: 'providerSeriesId',
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
referencedColumnName: 'provider',
|
||||
}])
|
||||
series: SeriesSubscriptionEntity[];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { UUID } from 'crypto';
|
||||
import { BookEntity } from 'src/books/entities/book.entity';
|
||||
import { Column, Entity, OneToMany, PrimaryColumn, Unique } from 'typeorm';
|
||||
import { SeriesSubscriptionEntity } from './series-subscription.entity';
|
||||
|
||||
@Entity("series")
|
||||
@Unique(['provider', 'providerSeriesId'])
|
||||
export class SeriesEntity {
|
||||
@PrimaryColumn({ name: 'series_id', type: 'uuid' })
|
||||
readonly seriesId: UUID;
|
||||
|
||||
@Column({ name: 'provider_series_id', type: 'text', nullable: true })
|
||||
providerSeriesId: string;
|
||||
|
||||
@Column({ name: 'series_title', type: 'text', nullable: false })
|
||||
title: string;
|
||||
|
||||
@Column({ name: 'media_type', type: 'text', nullable: true })
|
||||
mediaType: string;
|
||||
|
||||
@Column({ name: 'provider', type: 'text', nullable: false })
|
||||
provider: string;
|
||||
|
||||
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
|
||||
addedAt: Date;
|
||||
|
||||
@OneToMany(type => BookEntity, book => [book.provider, book.providerSeriesId])
|
||||
volumes: BookEntity[];
|
||||
|
||||
@OneToMany(type => SeriesSubscriptionEntity, subscription => [subscription.provider, subscription.providerSeriesId])
|
||||
subscriptions: SeriesSubscriptionEntity[];
|
||||
}
|
||||
24
backend/nestjs-seshat-api/src/series/series.module.ts
Normal file
24
backend/nestjs-seshat-api/src/series/series.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SeriesService } from './series.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SeriesEntity } from './entities/series.entity';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ProvidersModule } from 'src/providers/providers.module';
|
||||
import { SeriesSubscriptionEntity } from './entities/series-subscription.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
SeriesEntity,
|
||||
SeriesSubscriptionEntity,
|
||||
]),
|
||||
HttpModule,
|
||||
ProvidersModule,
|
||||
],
|
||||
controllers: [],
|
||||
exports: [
|
||||
SeriesService,
|
||||
],
|
||||
providers: [SeriesService]
|
||||
})
|
||||
export class SeriesModule { }
|
||||
18
backend/nestjs-seshat-api/src/series/series.service.spec.ts
Normal file
18
backend/nestjs-seshat-api/src/series/series.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SeriesService } from './series.service';
|
||||
|
||||
describe('SeriesService', () => {
|
||||
let service: SeriesService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [SeriesService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SeriesService>(SeriesService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
62
backend/nestjs-seshat-api/src/series/series.service.ts
Normal file
62
backend/nestjs-seshat-api/src/series/series.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { SeriesEntity } from './entities/series.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateSeriesDto } from './dto/create-series.dto';
|
||||
import { SeriesDto } from './dto/series.dto';
|
||||
import { SeriesSubscriptionEntity } from './entities/series-subscription.entity';
|
||||
import { UUID } from 'crypto';
|
||||
import { SeriesSubscriptionDto } from './dto/series-subscription.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SeriesService {
|
||||
constructor(
|
||||
@InjectRepository(SeriesEntity)
|
||||
private readonly seriesRepository: Repository<SeriesEntity>,
|
||||
@InjectRepository(SeriesSubscriptionEntity)
|
||||
private readonly seriesSubscriptionRepository: Repository<SeriesSubscriptionEntity>,
|
||||
) { }
|
||||
|
||||
|
||||
async addSeries(series: CreateSeriesDto) {
|
||||
return await this.seriesRepository.insert(series);
|
||||
}
|
||||
|
||||
async addSeriesSubscription(series: SeriesSubscriptionDto) {
|
||||
return await this.seriesSubscriptionRepository.insert(series);
|
||||
}
|
||||
|
||||
async deleteSeries(series: SeriesDto) {
|
||||
return await this.seriesRepository.delete(series);
|
||||
}
|
||||
|
||||
async deleteSeriesSubscription(subscription: SeriesSubscriptionDto) {
|
||||
return await this.seriesSubscriptionRepository.delete(subscription);
|
||||
}
|
||||
|
||||
async getSeries(series: SeriesDto) {
|
||||
return await this.seriesRepository.findOne({
|
||||
where: series
|
||||
});
|
||||
}
|
||||
|
||||
async getAllSeries() {
|
||||
return await this.seriesRepository.find()
|
||||
}
|
||||
|
||||
async getSeriesSubscribedBy(userId: UUID) {
|
||||
return await this.seriesRepository.createQueryBuilder('s')
|
||||
.select(['s.seriesId', 's.providerSeriesId', 's.provider', 's.title', 's.mediaType', 's.addedAt'])
|
||||
.innerJoinAndMapOne('s.subscriptions',
|
||||
qb => qb
|
||||
.select(['subscription.provider', 'subscription.provider_series_id', 'subscription.user_id'])
|
||||
.from(SeriesSubscriptionEntity, 'subscription'),
|
||||
'ss', `"ss"."subscription_provider" = "s"."provider" AND "ss"."provider_series_id" = "s"."provider_series_id"`)
|
||||
.where(`ss.user_id = :id`, { id: userId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async updateSeries(series: CreateSeriesDto) {
|
||||
return await this.seriesRepository.upsert(series, ['provider', 'providerSeriesId']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export enum BookOriginType {
|
||||
AUTHOR = 1,
|
||||
ILLUSTRATOR = 2,
|
||||
CATEGORY = 10,
|
||||
LANGUAGE = 11,
|
||||
MEDIA_TYPE = 12,
|
||||
MATURITY_RATING = 20,
|
||||
PROVIDER_THUMBNAIL = 30,
|
||||
PROVIDER_URL = 31,
|
||||
// 4x - Ratings
|
||||
ISBN_10 = 40,
|
||||
ISBN_13 = 41,
|
||||
// 1xx - User-defined
|
||||
TAGS = 100,
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class LoginUserDto {
|
||||
@IsNotEmpty()
|
||||
readonly username: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
readonly password: string;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as argon2 from 'argon2';
|
||||
import * as crypto from 'crypto';
|
||||
import { UUID } from "crypto";
|
||||
import { BookStatusEntity } from 'src/books/entities/book-status.entity';
|
||||
import { BigIntTransformer } from 'src/shared/transformers/bigint';
|
||||
import { StringToLowerCaseTransformer } from 'src/shared/transformers/string';
|
||||
import { BeforeInsert, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity({
|
||||
name: 'users'
|
||||
@@ -18,17 +19,20 @@ export class UserEntity {
|
||||
@Column({ name: 'user_name', nullable: false })
|
||||
userName: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
@Column({ name: 'password', nullable: false })
|
||||
password: string;
|
||||
|
||||
@Column({ type: 'bigint', nullable: false, transformer: BigIntTransformer })
|
||||
@Column({ name: 'salt', type: 'bigint', nullable: false, transformer: BigIntTransformer })
|
||||
salt: BigInt;
|
||||
|
||||
@Column({ name: 'is_admin', nullable: false })
|
||||
isAdmin: boolean;
|
||||
|
||||
@Column({ name: 'date_joined', type: 'timestamptz', nullable: false })
|
||||
dateJoined: Date;
|
||||
@Column({ name: 'joined_at', type: 'timestamptz', nullable: false })
|
||||
joinedAt: Date;
|
||||
|
||||
@OneToMany(type => BookStatusEntity, bookStatus => bookStatus.userId)
|
||||
bookStatuses: BookStatusEntity[];
|
||||
|
||||
@BeforeInsert()
|
||||
async hashPassword() {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { UserEntity } from './users.entity';
|
||||
import { UserEntity } from './entities/users.entity';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UsersController } from './users.controller';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
@@ -2,16 +2,16 @@ import * as argon2 from 'argon2';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserEntity } from './users.entity';
|
||||
import { LoginUserDto } from './dto/login-user.dto';
|
||||
import { UserEntity } from './entities/users.entity';
|
||||
import { UUID } from 'crypto';
|
||||
import { LoginDto } from 'src/auth/dto/login.dto';
|
||||
|
||||
class UserDto {
|
||||
userId: string;
|
||||
userId: UUID;
|
||||
userLogin: string;
|
||||
userName: string;
|
||||
isAdmin: boolean;
|
||||
dateJoined: Date;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -28,19 +28,19 @@ export class UsersService {
|
||||
userLogin: u.userLogin,
|
||||
userName: u.userName,
|
||||
isAdmin: u.isAdmin,
|
||||
dateJoined: u.dateJoined,
|
||||
joinedAt: u.joinedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async findOne({ username, password }: LoginUserDto): Promise<UserEntity> {
|
||||
const user = await this.userRepository.findOneBy({ userLogin: username });
|
||||
async findOne(loginDetails: LoginDto): Promise<UserEntity> {
|
||||
const user = await this.userRepository.findOneBy({ userLogin: loginDetails.user_login });
|
||||
if (!user) {
|
||||
// TODO: force an argon2.verify() to occur here.
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = Buffer.concat([
|
||||
Buffer.from(password, 'utf8'),
|
||||
Buffer.from(loginDetails.password, 'utf8'),
|
||||
Buffer.from(user.salt.toString(16), 'hex'),
|
||||
]);
|
||||
|
||||
|
||||
16
frontend/angular-seshat/.editorconfig
Normal file
16
frontend/angular-seshat/.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
42
frontend/angular-seshat/.gitignore
vendored
Normal file
42
frontend/angular-seshat/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
4
frontend/angular-seshat/.vscode/extensions.json
vendored
Normal file
4
frontend/angular-seshat/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
frontend/angular-seshat/.vscode/launch.json
vendored
Normal file
20
frontend/angular-seshat/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
frontend/angular-seshat/.vscode/tasks.json
vendored
Normal file
42
frontend/angular-seshat/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
27
frontend/angular-seshat/README.md
Normal file
27
frontend/angular-seshat/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# AngularSeshat
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.5.
|
||||
|
||||
## Development server
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
136
frontend/angular-seshat/angular.json
Normal file
136
frontend/angular-seshat/angular.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"angular-seshat": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/angular-seshat",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/custom-theme.scss",
|
||||
"./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": [],
|
||||
"server": "src/main.server.ts",
|
||||
"prerender": true,
|
||||
"ssr": {
|
||||
"entry": "server.ts"
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kB",
|
||||
"maximumError": "4kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "angular-seshat:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "angular-seshat:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"projectType": "library",
|
||||
"root": "projects/library",
|
||||
"sourceRoot": "projects/library/src",
|
||||
"prefix": "lib",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:ng-packagr",
|
||||
"options": {
|
||||
"project": "projects/library/ng-package.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"tsConfig": "projects/library/tsconfig.lib.prod.json"
|
||||
},
|
||||
"development": {
|
||||
"tsConfig": "projects/library/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"tsConfig": "projects/library/tsconfig.spec.json",
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14418
frontend/angular-seshat/package-lock.json
generated
Normal file
14418
frontend/angular-seshat/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
frontend/angular-seshat/package.json
Normal file
48
frontend/angular-seshat/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "angular-seshat",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"serve:ssr:angular-seshat": "node dist/angular-seshat/server/server.mjs"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^18.0.0",
|
||||
"@angular/cdk": "^18.2.14",
|
||||
"@angular/common": "^18.0.0",
|
||||
"@angular/compiler": "^18.0.0",
|
||||
"@angular/core": "^18.0.0",
|
||||
"@angular/forms": "^18.0.0",
|
||||
"@angular/material": "^18.2.14",
|
||||
"@angular/platform-browser": "^18.0.0",
|
||||
"@angular/platform-browser-dynamic": "^18.0.0",
|
||||
"@angular/platform-server": "^18.0.0",
|
||||
"@angular/router": "^18.0.0",
|
||||
"@angular/ssr": "^18.0.5",
|
||||
"express": "^4.18.2",
|
||||
"ngx-cookie-service": "^18.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^18.0.5",
|
||||
"@angular/cli": "^18.0.5",
|
||||
"@angular/compiler-cli": "^18.0.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/node": "^18.18.0",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"ng-packagr": "^18.2.0",
|
||||
"typescript": "~5.4.2"
|
||||
}
|
||||
}
|
||||
BIN
frontend/angular-seshat/public/favicon.ico
Normal file
BIN
frontend/angular-seshat/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M80-140v-320h320v320H80Zm80-80h160v-160H160v160Zm60-340 220-360 220 360H220Zm142-80h156l-78-126-78 126ZM863-42 757-148q-21 14-45.5 21t-51.5 7q-75 0-127.5-52.5T480-300q0-75 52.5-127.5T660-480q75 0 127.5 52.5T840-300q0 26-7 50.5T813-204L919-98l-56 56ZM660-200q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29ZM320-380Zm120-260Z"/></svg>
|
||||
|
After Width: | Height: | Size: 472 B |
1
frontend/angular-seshat/public/icons/close_icon.svg
Normal file
1
frontend/angular-seshat/public/icons/close_icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>
|
||||
|
After Width: | Height: | Size: 222 B |
1
frontend/angular-seshat/public/icons/error_icon.svg
Normal file
1
frontend/angular-seshat/public/icons/error_icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
|
After Width: | Height: | Size: 537 B |
1
frontend/angular-seshat/public/icons/search_icon.svg
Normal file
1
frontend/angular-seshat/public/icons/search_icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>
|
||||
|
After Width: | Height: | Size: 376 B |
1
frontend/angular-seshat/public/icons/warning_icon.svg
Normal file
1
frontend/angular-seshat/public/icons/warning_icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="m40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z"/></svg>
|
||||
|
After Width: | Height: | Size: 314 B |
57
frontend/angular-seshat/server.ts
Normal file
57
frontend/angular-seshat/server.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { APP_BASE_HREF } from '@angular/common';
|
||||
import { CommonEngine } from '@angular/ssr';
|
||||
import express from 'express';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import bootstrap from './src/main.server';
|
||||
|
||||
// The Express app is exported so that it can be used by serverless Functions.
|
||||
export function app(): express.Express {
|
||||
const server = express();
|
||||
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
||||
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
||||
const indexHtml = join(serverDistFolder, 'index.server.html');
|
||||
|
||||
const commonEngine = new CommonEngine();
|
||||
|
||||
server.set('view engine', 'html');
|
||||
server.set('views', browserDistFolder);
|
||||
|
||||
// Example Express Rest API endpoints
|
||||
// server.get('/api/**', (req, res) => { });
|
||||
// Serve static files from /browser
|
||||
server.get('**', express.static(browserDistFolder, {
|
||||
maxAge: '1y',
|
||||
index: 'index.html',
|
||||
}));
|
||||
|
||||
// All regular routes use the Angular engine
|
||||
server.get('**', (req, res, next) => {
|
||||
const { protocol, originalUrl, baseUrl, headers } = req;
|
||||
|
||||
commonEngine
|
||||
.render({
|
||||
bootstrap,
|
||||
documentFilePath: indexHtml,
|
||||
url: `${protocol}://${headers.host}${originalUrl}`,
|
||||
publicPath: browserDistFolder,
|
||||
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
|
||||
})
|
||||
.then((html) => res.send(html))
|
||||
.catch((err) => next(err));
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const port = process.env['PORT'] || 4000;
|
||||
|
||||
// Start up the Node server
|
||||
const server = app();
|
||||
server.listen(port, () => {
|
||||
console.log(`Node Express server listening on http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
run();
|
||||
10
frontend/angular-seshat/src/app/app.component.css
Normal file
10
frontend/angular-seshat/src/app/app.component.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.loading-container {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
}
|
||||
6
frontend/angular-seshat/src/app/app.component.html
Normal file
6
frontend/angular-seshat/src/app/app.component.html
Normal file
@@ -0,0 +1,6 @@
|
||||
@if (loading) {
|
||||
<div class="loading-container flex-content-center">
|
||||
<div>hello, loading world.</div>
|
||||
</div>
|
||||
}
|
||||
<router-outlet />
|
||||
29
frontend/angular-seshat/src/app/app.component.spec.ts
Normal file
29
frontend/angular-seshat/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have the 'angular-seshat' title`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('angular-seshat');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, angular-seshat');
|
||||
});
|
||||
});
|
||||
53
frontend/angular-seshat/src/app/app.component.ts
Normal file
53
frontend/angular-seshat/src/app/app.component.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Component, inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { AuthService } from './services/auth/auth.service';
|
||||
import { ConfigService } from './services/config.service';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { LoadingService } from './services/loading.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RedirectionService } from './services/redirection.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.css'
|
||||
})
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
private readonly _auth = inject(AuthService);
|
||||
private readonly _config = inject(ConfigService);
|
||||
private readonly _loading = inject(LoadingService);
|
||||
private readonly _platformId = inject(PLATFORM_ID);
|
||||
private readonly _redirect = inject(RedirectionService);
|
||||
private readonly _subscriptions: Subscription[] = [];
|
||||
|
||||
loading: boolean = false;
|
||||
|
||||
ngOnInit() {
|
||||
if (!isPlatformBrowser(this._platformId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listenToLoading();
|
||||
|
||||
this._config.fetch();
|
||||
this._auth.update();
|
||||
|
||||
this._loading.listenUntilReady()
|
||||
.subscribe(async () => {
|
||||
this._redirect.redirect(null);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._subscriptions.forEach(s => s.unsubscribe());
|
||||
}
|
||||
|
||||
listenToLoading(): void {
|
||||
this._subscriptions.push(
|
||||
this._loading.listen()
|
||||
.subscribe((loading) => this.loading = loading)
|
||||
);
|
||||
}
|
||||
}
|
||||
11
frontend/angular-seshat/src/app/app.config.server.ts
Normal file
11
frontend/angular-seshat/src/app/app.config.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
||||
import { provideServerRendering } from '@angular/platform-server';
|
||||
import { appConfig } from './app.config';
|
||||
|
||||
const serverConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideServerRendering()
|
||||
],
|
||||
};
|
||||
|
||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||
23
frontend/angular-seshat/src/app/app.config.ts
Normal file
23
frontend/angular-seshat/src/app/app.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
import { provideClientHydration } from '@angular/platform-browser';
|
||||
import { provideHttpClient, withInterceptorsFromDi, withFetch, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { LoadingInterceptor } from './shared/interceptors/loading.interceptor';
|
||||
import { TokenValidationInterceptor } from './shared/interceptors/token-validation.interceptor';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideClientHydration(),
|
||||
provideHttpClient(
|
||||
withInterceptorsFromDi(),
|
||||
withFetch()
|
||||
),
|
||||
LoadingInterceptor,
|
||||
TokenValidationInterceptor,
|
||||
provideAnimationsAsync(),
|
||||
]
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user