Compare commits

..

36 Commits

Author SHA1 Message Date
Tom
7ef7e372e2 Fixed regex for some publishers. 2025-06-26 18:17:03 +00:00
Tom
71e232380b mediaType is now optional when asking to store series. 2025-06-26 18:16:11 +00:00
Tom
c2d06446eb Changed GET /library/books to just fetch stored books. 2025-06-26 18:15:22 +00:00
Tom
1de822da14 Added author to search filters. 2025-06-26 14:36:01 +00:00
Tom
f735d1631f Added media item modal when user clicks on an item to save/subscribe a new media. 2025-06-26 14:35:51 +00:00
Tom
89b29c58dc Added Angular Material. 2025-06-25 14:53:10 +00:00
Tom
60e179cd13 Added update for initial values for filters. 2025-06-25 14:50:31 +00:00
Tom
7875c5407c Added a minor loading animation when searching. 2025-06-25 14:49:05 +00:00
Tom
3326b7c589 Fixed infinite scrolling. 2025-06-25 00:44:18 +00:00
Tom
e20231639c Added angular website, with login, registration & searching. 2025-06-24 20:36:51 +00:00
Tom
a0e8506027 Added WEB_SECURE to environment file. Added https replacement on Google thumbnails if WEB_SECURE is true. 2025-06-24 18:37:46 +00:00
Tom
26abb6163f Added configuration for search for frontend. 2025-06-24 18:36:15 +00:00
Tom
e7fc6e0802 Removed renewing refresh tokens. Fixed error 500 when using expired tokens. Fixed log out response when user only has access token." 2025-06-20 20:51:34 +00:00
Tom
8ac848e8f1 Changed library search via API to use search context. 2025-06-20 15:40:08 +00:00
Tom
0bfdded52f Changed environment variable name for API port. 2025-06-20 14:46:50 +00:00
Tom
03286c2013 App Configuration is now read from file. 2025-06-19 16:01:15 +00:00
Tom
cc337d22f2 Fixed/cleaned auth validation. Added 404 response when registrations are disabled. 2025-06-19 15:25:18 +00:00
Tom
bde574ccad Added app configuration, for now specific to user registration. 2025-06-18 16:51:46 +00:00
Tom
6ac9a2f1ec Denied access to login & register while logged in. Fixed regular user auth for requiring admin. 2025-06-18 13:50:38 +00:00
Tom
6b010f66ba Removed renewing refresh token. Added validate endpoint for tokens. Refresh token is given only if 'remember me' option is enabled on login. 2025-06-17 16:41:46 +00:00
Tom
c7ece75e7a Added series subscriptions. Added series searching. Fixed database relations. Added logging for library controller. 2025-03-07 16:06:08 +00:00
Tom
4aafe86ef0 Searching while updating series now uses latest published date of the series as a stop reference. 2025-03-04 04:59:13 +00:00
Tom
4b7417c39b Fixed output for updating series. 2025-03-03 21:34:00 +00:00
Tom
d02da321a1 Changed book status to smallint. Added media_type to series. Added 'Hanashi Media' regex resolver for searching. Removed 'Fiction' limitation when searching. Added update series to add new volumes. Fixed search when not all volumes would show up. 2025-03-03 21:18:46 +00:00
Tom
d0c074135e Removed useless dependencies on Books module. 2025-02-28 17:15:15 +00:00
Tom
7e828b1662 Added more logs for jobs. 2025-02-28 17:14:48 +00:00
Tom
6b5bfa963e Added logs for queues. 2025-02-28 02:46:02 +00:00
Tom
969829da20 Create Library module. Moved book controller to library controller. Added series addition to library while adding all known volumes in background. Fixed Google search context. 2025-02-28 00:20:29 +00:00
Tom
64ebdfd6f4 Fixed Pino logging dependency issues. 2025-02-24 21:18:36 +00:00
Tom
a44cd89072 Added modules for books & series. 2025-02-24 20:54:58 +00:00
Tom
8f0ca1ce58 Updated the SQL file to support series subscriptions in the future, among other changes. 2025-02-20 04:44:26 +00:00
Tom
cd3ba11924 Added fine-grained control over search for server use. 2025-02-20 04:36:48 +00:00
Tom
a764e1d441 Fixed authentication. 2025-02-19 22:04:03 +00:00
Tom
16f208480d Added environment config. Added logging serializers. Updated logs to use serializers. 2025-02-12 22:08:07 +00:00
Tom
abb8bec0cf Added file/json logging. 2025-02-12 20:29:15 +00:00
Tom
a0909bfd21 Improved & fixed some issues with JWT authentication. 2025-02-12 17:21:48 +00:00
154 changed files with 21071 additions and 500 deletions

View File

@@ -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);

View 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"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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],

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigController } from './config/config.controller';
@Module({
controllers: [ConfigController]
})
export class AssetModule {}

View 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);

View File

@@ -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();
});
});

View File

@@ -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
};
}
}

View File

@@ -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')
}
);
}
}

View File

@@ -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,
};
}
}
}

View File

@@ -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]
})

View File

@@ -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'),
}
);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,4 @@
export class AccessTokenDto {
access_token: string;
exp: number;
}

View File

@@ -0,0 +1,6 @@
export class AuthenticationDto {
access_token: string;
exp: number;
refresh_token: string | null;
refresh_exp: number | null;
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -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();
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -1,6 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LoginAuthGuard extends AuthGuard('login') { }

View File

@@ -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;
}
}

View File

@@ -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'),
},
};
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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 { }

View 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();
});
});

View 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();
}
}

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from "class-validator";
export class BookOriginDto {
@IsString()
@IsNotEmpty()
bookOriginId: string;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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,

View 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),
}
}
}

View File

@@ -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();
});
});

View 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,
};
}
}

View 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 { }

View File

@@ -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();
});
});

View 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);
}
}

View 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,
}
}

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
});
});

View 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;
}
}
}

View File

@@ -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();
});
});

View File

@@ -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);
}
}

View 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 {}

View File

@@ -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();
});
});

View 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.');
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class SeriesDto {
@IsString()
@IsNotEmpty()
providerSeriesId: string;
@IsString()
@IsNotEmpty()
provider: string;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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[];
}

View 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 { }

View 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();
});
});

View 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']);
}
}

View File

@@ -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,
}

View File

@@ -1,9 +0,0 @@
import { IsNotEmpty } from 'class-validator';
export class LoginUserDto {
@IsNotEmpty()
readonly username: string;
@IsNotEmpty()
readonly password: string;
}

View File

@@ -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() {

View File

@@ -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';

View File

@@ -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'),
]);

View 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
View 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

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View 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"
}
]
}

View 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"
}
}
}
}
]
}

View 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.

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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="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

View 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

View 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

View 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

View 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

View 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();

View 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;
}

View File

@@ -0,0 +1,6 @@
@if (loading) {
<div class="loading-container flex-content-center">
<div>hello, loading world.</div>
</div>
}
<router-outlet />

View 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');
});
});

View 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)
);
}
}

View 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);

View 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