Compare commits

..

27 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
124 changed files with 18241 additions and 393 deletions

View File

@@ -25,6 +25,7 @@ CREATE TABLE
-- 3rd party id for this series.
provider_series_id text,
series_title text NOT NULL,
media_type text,
-- 3rd party used to fetch the data for this series.
provider varchar(12) NOT NULL,
added_at timestamp default NULL,
@@ -53,7 +54,6 @@ CREATE TABLE
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)
);
@@ -113,7 +113,7 @@ CREATE TABLE
book_statuses (
user_id uuid,
book_id uuid,
state varchar(12),
state smallint,
added_at timestamp default NULL,
modified_at timestamp default NULL,
PRIMARY KEY (user_id, book_id),
@@ -130,7 +130,7 @@ CREATE INDEX book_statuses_user_id_login_idx ON users (user_id);
CREATE TABLE
series_subscriptions (
user_id uuid,
provider text,
provider varchar(12) NOT NULL,
provider_series_id text,
added_at timestamp default NULL,
PRIMARY KEY (user_id, provider, provider_series_id),

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

View File

@@ -11,12 +11,13 @@ import { UsersModule } from './users/users.module';
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 } from './logging.serializers';
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: [
@@ -51,12 +52,14 @@ import { BullModule } from '@nestjs/bullmq';
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),
},
@@ -72,7 +75,9 @@ import { BullModule } from '@nestjs/bullmq';
BooksModule,
ProvidersModule,
SeriesModule,
LibraryModule
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

@@ -1,15 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BooksController } from './books.controller';
import { ConfigController } from './config.controller';
describe('BooksController', () => {
let controller: BooksController;
describe('ConfigController', () => {
let controller: ConfigController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [BooksController],
controllers: [ConfigController],
}).compile();
controller = module.get<BooksController>(BooksController);
controller = module.get<ConfigController>(ConfigController);
});
it('should be defined', () => {

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

@@ -4,6 +4,7 @@ import { JwtService } from '@nestjs/jwt';
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 {
@@ -13,7 +14,7 @@ export class AuthAccessService {
private logger: PinoLogger,
) { }
async generate(user: UserEntity) {
async generate(user: UserEntity): Promise<AccessTokenDto> {
const now = new Date();
const limit = parseInt(this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_EXPIRATION_MS'));
const expiration = moment(now).add(limit, 'ms').toDate();
@@ -45,4 +46,12 @@ export class AuthAccessService {
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,5 +1,4 @@
import { Controller, Request, Post, UseGuards, Body, Res, Delete, Patch } 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';
@@ -9,7 +8,10 @@ import { OfflineGuard } from './guards/offline.guard';
import { UserEntity } from 'src/users/entities/users.entity';
import { QueryFailedError } from 'typeorm';
import { PinoLogger } from 'nestjs-pino';
import { JwtAccessGuard } from './guards/jwt-access.guard';
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 {
@@ -19,16 +21,17 @@ export class AuthController {
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 {
data = await this.auth.login(req.user);
if (!data.access_token || !data.refresh_token || !data.refresh_exp) {
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,
@@ -44,6 +47,14 @@ export class AuthController {
error: err,
});
if (err instanceof UnauthorizedException) {
response.statusCode = 401;
return {
success: false,
error_message: 'Invalid credentials.',
};
}
response.statusCode = 500;
return {
success: false,
@@ -58,12 +69,14 @@ export class AuthController {
sameSite: 'strict',
});
response.cookie('Refresh', data.refresh_token, {
httpOnly: true,
secure: true,
expires: new Date(data.refresh_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,
@@ -71,6 +84,7 @@ export class AuthController {
user: req.user,
access_token: data.access_token,
refresh_token: data.refresh_token,
remember_me: body.remember_me,
msg: 'User logged in.',
});
@@ -79,7 +93,7 @@ export class AuthController {
};
}
@UseGuards(JwtAccessGuard)
@UseGuards(JwtMixedGuard)
@Delete('login')
async logout(
@Request() req,
@@ -91,19 +105,19 @@ export class AuthController {
response.clearCookie('Authentication');
response.clearCookie('Refresh');
if (!refreshToken || !await this.auth.revoke(req.user.userId, refreshToken)) {
if (!accessToken && !refreshToken && !await this.auth.revoke(req.user.userId, refreshToken)) {
// User has already logged off.
this.logger.info({
class: AuthController.name,
method: this.login.name,
method: this.logout.name,
user: req.user,
msg: 'User has already logged off via ' + (!refreshToken ? 'cookies' : 'database'),
msg: 'User has already logged off based on ' + (!refreshToken ? 'cookies' : 'database'),
});
response.statusCode = 400;
return {
success: false,
error_message: 'User has already logged off.'
error_message: 'User has already logged off.',
};
}
@@ -127,14 +141,31 @@ export class AuthController {
) {
this.logger.info({
class: AuthController.name,
method: this.login.name,
method: this.refresh.name,
user: req.user,
refresh_token: req.cookies.Refresh,
msg: 'User logged in.',
msg: 'Attempting to renew access token.',
});
const refreshToken = req.cookies.Refresh;
const data = await this.auth.renew(req.user, refreshToken);
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,
@@ -150,22 +181,6 @@ export class AuthController {
msg: 'Updated Authentication cookie for access token.',
});
if (data.refresh_token != refreshToken) {
response.cookie('Refresh', data.refresh_token, {
httpOnly: true,
secure: true,
expires: new Date(data.refresh_exp),
sameSite: 'strict',
});
this.logger.debug({
class: AuthController.name,
method: this.refresh.name,
user: req.user,
refresh_token: data.refresh_token,
msg: 'Updated Refresh cookie for refresh token.',
});
}
return { success: true };
}
@@ -176,6 +191,14 @@ 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 {
@@ -220,8 +243,12 @@ export class AuthController {
}
try {
data = await this.auth.login(user);
if (!data.access_token || !data.refresh_token || !data.refresh_exp) {
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,
@@ -246,6 +273,15 @@ export class AuthController {
error: err,
});
// This should never happen...
if (err instanceof UnauthorizedException) {
response.statusCode = 401;
return {
success: false,
error_message: 'Invalid credentials.',
};
}
response.statusCode = 500;
return {
success: false,
@@ -260,15 +296,31 @@ export class AuthController {
sameSite: 'strict',
});
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) {
response.statusCode = 500;
return {
success: false,
error_message: err,
};
}
}
}

View File

@@ -2,12 +2,11 @@ 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';
@@ -35,9 +34,8 @@ import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
AuthAccessService,
AuthRefreshService,
AuthService,
JwtStrategy,
JwtAccessStrategy,
JwtRefreshStrategy,
LoginStrategy,
],
controllers: [AuthController]
})

View File

@@ -1,6 +1,6 @@
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';
@@ -20,97 +20,46 @@ export class AuthRefreshService {
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) {
this.logger.warn({
class: AuthRefreshService.name,
method: this.generate.name,
user,
refresh_token: refreshToken,
msg: 'Refresh token given is invalid.',
});
throw new UnauthorizedException('Invalid refresh token.');
}
if (token.exp.getTime() < new Date().getTime()) {
this.logger.warn({
class: AuthRefreshService.name,
method: this.generate.name,
user,
refresh_token: refreshToken,
exp: expiration,
msg: 'Refresh token given has expired.',
});
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 expirationTime = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_MS'));
const threshhold = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_THRESHHOLD_MS'));
if (!refreshToken || expirationTime - (expiration.getTime() - now.getTime()) > threshhold) {
let deletionTask = null;
if (refreshToken) {
deletionTask = this.revoke(user.userId, refreshToken);
this.logger.debug({
class: AuthRefreshService.name,
method: this.generate.name,
user,
refresh_token: refreshToken,
exp: expiration,
msg: 'Deleted previous refresh token.',
});
const expiration = moment(now).add(expirationTime, 'ms').toDate();
const refreshToken = await this.jwts.signAsync(
{
username: user.userLogin,
sub: user.userId,
iat: Math.floor(now.getTime() / 1000),
nbf: Math.floor(now.getTime() / 1000) - 5 * 60,
exp: Math.floor(expiration.getTime() / 1000),
},
{
secret: this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_SECRET'),
}
);
expiration = moment(now).add(expirationTime, 'ms').toDate();
refreshToken = await this.jwts.signAsync(
{
username: user.userLogin,
sub: user.userId,
iat: Math.floor(now.getTime() / 1000),
nbf: Math.floor(now.getTime() / 1000) - 5 * 60,
exp: Math.floor(expiration.getTime() / 1000),
},
{
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.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: this.hash(refreshToken),
userId: user.userId,
exp: expiration
});
this.authRefreshTokenRepository.insert({
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.',
});
await deletionTask;
}
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,
@@ -155,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,9 +1,13 @@
import { Injectable } from '@nestjs/common';
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 {
@@ -14,33 +18,127 @@ export class AuthService {
) { }
async login(user: UserEntity): Promise<AuthenticationDto> {
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 | null
): Promise<AuthenticationDto | null> {
const new_refresh_data = await this.refreshTokens.generate(user, refresh_token);
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_data.refresh_token,
refresh_exp: new_refresh_data.exp,
}
validation: true,
userId: (access ?? refresh).sub,
username: (access ?? refresh).username,
};
}
async revoke(userId: UUID, refreshToken: string): Promise<boolean> {
async revoke(
userId: UUID,
refreshToken: string
): Promise<boolean> {
const res = await this.refreshTokens.revoke(userId, refreshToken);
return res?.affected === 1
return res?.affected === 1;
}
}

View File

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

View File

@@ -1,4 +1,4 @@
class AuthenticationDto {
export class AuthenticationDto {
access_token: string;
exp: number;
refresh_token: string | 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

@@ -5,7 +5,7 @@ import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAccessGuard extends AuthGuard('jwt-access') {
handleRequest(err, user, info) {
if (err || !user || !user.isAdmin) {
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

@@ -6,12 +6,12 @@ import { ConfigService } from '@nestjs/config';
import { UsersService } from 'src/users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-access') {
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') {
constructor(private users: UsersService, private config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
//ExtractJwt.fromAuthHeaderAsBearerToken(),
JwtStrategy.extract,
JwtAccessStrategy.extract,
]),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET'),

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

@@ -5,11 +5,7 @@ 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 { ProvidersModule } from 'src/providers/providers.module';
import { SeriesModule } from 'src/series/series.module';
import { LibraryService } from 'src/library/library.service';
import { LibraryModule } from 'src/library/library.module';
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
@@ -20,16 +16,11 @@ import { BullModule } from '@nestjs/bullmq';
]),
SeriesModule,
HttpModule,
ProvidersModule,
LibraryModule,
BullModule.registerQueue({
name: 'library',
}),
],
controllers: [],
exports: [
BooksService
],
providers: [BooksService, LibraryService]
providers: [BooksService]
})
export class BooksModule { }

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BookEntity } from './entities/book.entity';
import { In, InsertResult, Repository } from 'typeorm';
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';
@@ -9,6 +9,9 @@ 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 {
@@ -36,16 +39,18 @@ export class BooksService {
return await this.bookOriginRepository.insert(origin);
}
async deleteBookOrigin(origin: CreateBookOriginDto) {
async deleteBookOrigin(origin: BookOriginDto[]): Promise<DeleteResult> {
return await this.bookOriginRepository.createQueryBuilder()
.delete()
.where({
whereFactory: origin,
whereFactory: {
bookOriginId: In(origin.map(o => o.bookOriginId)),
},
})
.execute();
}
async deleteBookStatus(status: DeleteBookStatusDto) {
async deleteBookStatus(status: DeleteBookStatusDto): Promise<DeleteResult> {
return await this.bookStatusRepository.createQueryBuilder()
.delete()
.where({
@@ -54,7 +59,7 @@ export class BooksService {
.execute();
}
async findBooksByIds(bookIds: UUID[]) {
async findBooksByIds(bookIds: UUID[]): Promise<BookEntity[]> {
return await this.bookRepository.find({
where: {
bookId: In(bookIds)
@@ -62,25 +67,36 @@ export class BooksService {
});
}
async findBookStatusesTrackedBy(userId: UUID): Promise<BookStatusEntity[]> {
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')
.select(['s.book_id', 's.user_id'])
.where('s.user_id = :id', { id: userId })
.innerJoin('s.book', 'b')
.addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider'])
.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 findSeriesTrackedBy(userId: UUID) {
return await this.bookStatusRepository.createQueryBuilder('s')
.where({
whereFactory: {
userId: userId
}
})
.innerJoin('s.book', 'b')
.addSelect(['b.provider', 'b.providerSeriesId'])
.distinctOn(['b.provider', 'b.providerSeriesId'])
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();
}
@@ -101,7 +117,7 @@ export class BooksService {
await this.bookStatusRepository.createQueryBuilder()
.insert()
.values(status)
.orUpdate(['user_id', 'book_id'], ['state', 'modified_at'], { skipUpdateIfNoValuesChanged: true })
.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

@@ -1,4 +1,4 @@
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { UUID } from 'crypto';
export class CreateBookStatusDto {
@@ -11,9 +11,11 @@ export class CreateBookStatusDto {
@IsOptional()
readonly userId: UUID;
@IsString()
@IsNumber()
@IsNotEmpty()
state: string;
@Min(0)
@Max(6)
state: number;
modifiedAt: Date;
}

View File

@@ -1,6 +1,6 @@
import { UUID } from 'crypto';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { BookEntity } from './book.entity';
@Entity("book_origins")
@@ -19,6 +19,9 @@ export class BookOriginEntity {
value: string;
@OneToOne(type => BookEntity, book => book.metadata)
@JoinColumn({ name: 'book_id' })
@JoinColumn({
name: 'book_id',
referencedColumnName: 'bookId',
})
book: BookEntity;
}

View File

@@ -1,6 +1,6 @@
import { UUID } from 'crypto';
import { UserEntity } from 'src/users/entities/users.entity';
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryColumn } from 'typeorm';
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
import { BookEntity } from './book.entity';
@Entity("book_statuses")
@@ -11,8 +11,8 @@ export class BookStatusEntity {
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
readonly userId: UUID;
@Column({ name: 'state', type: 'varchar' })
state: string;
@Column({ name: 'state', type: 'smallint' })
state: number;
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date
@@ -21,10 +21,16 @@ export class BookStatusEntity {
modifiedAt: Date;
@OneToOne(type => BookEntity, book => book.statuses)
@JoinColumn({ name: 'book_id' })
@JoinColumn({
name: 'book_id',
referencedColumnName: 'bookId',
})
book: BookEntity;
@OneToOne(type => UserEntity, user => user.bookStatuses)
@JoinColumn({ name: 'user_id' })
@JoinColumn({
name: 'user_id',
referencedColumnName: 'userId',
})
user: UserEntity;
}

View File

@@ -1,5 +1,5 @@
import { UUID } from 'crypto';
import { Column, Entity, JoinColumn, OneToMany, OneToOne, PrimaryColumn, Unique } from 'typeorm';
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';
@@ -34,14 +34,20 @@ export class BookEntity {
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date;
@OneToMany(type => BookOriginEntity, origin => origin.bookId)
@OneToMany(type => BookOriginEntity, origin => origin.book)
metadata: BookOriginEntity[];
@OneToMany(type => BookStatusEntity, status => status.bookId)
@OneToMany(type => BookStatusEntity, status => status.book)
statuses: BookStatusEntity[];
@OneToOne(type => SeriesEntity, series => series.volumes)
@JoinColumn({ name: 'provider_series_id' })
@JoinColumn({ name: 'provider' })
@JoinColumn([{
name: 'provider_series_id',
referencedColumnName: 'providerSeriesId',
},
{
name: 'provider',
referencedColumnName: 'provider',
}])
series: SeriesEntity;
}

View File

@@ -1,11 +1,11 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
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 { CreateSeriesSubscriptionJobDto } from 'src/series/dto/create-series-subscription-job.dto';
import { SeriesSubscriptionJobDto } from 'src/series/dto/series-subscription-job.dto';
import { LibraryService } from './library.service';
@Processor('library')
@@ -19,13 +19,93 @@ export class LibraryConsumer extends WorkerHost {
}
async process(job: Job, token?: string): Promise<any> {
console.log('job started:', job.name, job.data, job.id);
const series: CreateSeriesSubscriptionJobDto = job.data;
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.intitle = series.title;
context.maxResults = '40';
context.subject = 'Fiction';
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.
@@ -36,7 +116,7 @@ export class LibraryConsumer extends WorkerHost {
do {
pageSearchedCount += 1;
results = await this.provider.search(context);
const potential = results.filter(r => r.providerSeriesId == series.providerSeriesId || r.title == series.title);
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 {
@@ -44,7 +124,7 @@ export class LibraryConsumer extends WorkerHost {
}
context = context.next();
job.updateProgress(pageSearchedCount * 5);
} while (results.length >= 40 && unhelpfulResultsCount < 3);
} 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))
@@ -52,32 +132,60 @@ export class LibraryConsumer extends WorkerHost {
.filter((_, index, arr) => index == 0 || arr[index - 1].result.volume != arr[index].result.volume);
job.updateProgress(25);
let counter = 0;
for (let book of books) {
try {
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.',
error: err,
});
} finally {
counter++;
job.updateProgress(25 + 75 * counter / books.length);
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,
}
}
});
console.log('job completed:', job.name, job.data, job.id);
return null;
return books;
}
private toScore(book: BookSearchResultDto, series: CreateSeriesSubscriptionJobDto): ({ result: BookSearchResultDto, score: number }) {
@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,

View File

@@ -1,5 +1,5 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Body, Controller, Delete, Get, Post, Put, Request, Res, UseGuards } from '@nestjs/common';
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';
@@ -10,11 +10,13 @@ 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 { CreateSeriesSubscriptionDto } from 'src/series/dto/create-series-subscription.dto';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { SeriesDto } from 'src/series/dto/series.dto';
import { CreateBookOriginDto } from 'src/books/dto/create-book-origin.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')
@@ -30,7 +32,6 @@ export class LibraryController {
@Get('series')
async getSeries(
@Request() req,
@Res({ passthrough: true }) response: Response,
) {
return {
success: true,
@@ -41,15 +42,11 @@ export class LibraryController {
@Post('series')
async createSeries(
@Request() req,
@Body() body: CreateSeriesSubscriptionDto,
@Body() body: CreateSeriesDto,
@Res({ passthrough: true }) response: Response,
) {
try {
await this.library.addSeries({
provider: body.provider,
providerSeriesId: body.providerSeriesId,
title: body.title,
});
await this.library.addSeries(body);
return {
success: true,
@@ -57,6 +54,63 @@ export class LibraryController {
} 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 {
@@ -66,6 +120,64 @@ export class LibraryController {
}
}
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,
@@ -77,7 +189,6 @@ export class LibraryController {
@Get('series/subscriptions')
async getSeriesSubscriptions(
@Request() req,
@Res({ passthrough: true }) response: Response,
) {
return {
success: true,
@@ -103,6 +214,15 @@ export class LibraryController {
} 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 {
@@ -110,8 +230,17 @@ export class LibraryController {
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 = 400;
response.statusCode = 404;
return {
success: false,
error_message: 'Series does not exist.',
@@ -119,6 +248,15 @@ export class LibraryController {
}
}
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,
@@ -127,13 +265,27 @@ export class LibraryController {
}
}
@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.books.findBookStatusesTrackedBy(req.user.userId),
data: await this.library.findBooks(),
};
}
@@ -144,23 +296,18 @@ export class LibraryController {
@Res({ passthrough: true }) response: Response,
) {
if (body.provider && body.providerSeriesId) {
try {
await this.series.updateSeries({
provider: body.provider,
providerSeriesId: body.providerSeriesId,
title: body.title,
});
} catch (err) {
if (err instanceof QueryFailedError) {
// Ignore if the series already exist.
if (err.driverError.code != '23505') {
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
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.',
}
}
@@ -172,6 +319,15 @@ export class LibraryController {
} 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 {
@@ -179,11 +335,20 @@ export class LibraryController {
error_message: 'The book has already been added previously.',
};
} else if (err.driverError.code == '23503') {
// Data dependency is missing.
response.statusCode = 500;
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 has not been added.',
error_message: 'Series does not exist.',
};
}
}
@@ -192,7 +357,8 @@ export class LibraryController {
class: LibraryController.name,
method: this.createBook.name,
user: req.user,
msg: 'Failed to create book.',
body: body,
msg: 'Failed to create a book.',
error: err,
});
@@ -213,33 +379,17 @@ export class LibraryController {
const result = await this.books.updateBook(body.bookId, data);
return {
success: result?.affected == 1,
success: result && result.affected > 0,
};
}
@Delete('books/origins')
async deleteBookOrigin(
@Body() body: CreateBookOriginDto,
@Body() body: BookOriginDto[],
) {
const data = { ...body };
delete data['bookOriginId'];
const result = await this.books.deleteBookOrigin(body);
return {
success: result?.affected == 1,
};
}
@Delete('books/status')
async deleteBookStatus(
@Body() body: DeleteBookStatusDto,
) {
const data = { ...body };
delete data['bookOriginId'];
const result = await this.books.deleteBookStatus(body);
return {
success: result?.affected == 1,
success: result && result.affected > 0,
};
}
@@ -252,7 +402,76 @@ export class LibraryController {
const result = await this.books.updateBookOrigin(body.bookOriginId, data);
return {
success: result?.affected == 1,
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

@@ -31,6 +31,7 @@ import { LibraryController } from './library.controller';
HttpModule,
ProvidersModule,
],
exports: [LibraryService],
providers: [LibraryService, BooksService, SeriesService, LibraryConsumer],
controllers: [LibraryController]
})

View File

@@ -3,10 +3,10 @@ import { Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { BooksService } from 'src/books/books.service';
import { CreateBookDto } from 'src/books/dto/create-book.dto';
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';
@@ -26,7 +26,7 @@ export class LibraryService {
this.logger.debug({
class: LibraryService.name,
method: this.addSubscription.name,
series: series.providerSeriesId,
series: series,
msg: 'Series saved to database.',
});
@@ -135,4 +135,16 @@ export class LibraryService {
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

@@ -21,6 +21,7 @@ export function serialize_user_long(value: UserEntity) {
}
export function serialize_token(value: string) {
if (!value) return null;
return '...' + value.substring(Math.max(value.length - 12, value.length / 2) | 0);
}
@@ -79,3 +80,15 @@ export function serialize_res(value) {
}
return value;
}
export function serialize_job(value) {
if (!value) {
return value;
}
return {
id: value.id,
name: value.name,
data: value.data,
}
}

View File

@@ -14,6 +14,6 @@ async function bootstrap() {
}));
app.useLogger(app.get(Logger));
app.useGlobalInterceptors(new LoggerErrorInterceptor());
await app.listen(process.env.PORT ?? 3001);
await app.listen(process.env.WEB_API_PORT ?? 3001);
}
bootstrap();

View File

@@ -6,8 +6,8 @@ export class GoogleSearchContext extends SearchContext {
}
generateQueryParams() {
const filterParams = ['maxResults', 'startIndex'];
generateQueryParams(): string {
const filterParams = ['maxResults', 'startIndex', 'orderBy', 'langRestrict'];
const searchParams = ['intitle', 'inauthor', 'inpublisher', 'subject', 'isbn'];
const queryParams = filterParams
@@ -21,7 +21,19 @@ export class GoogleSearchContext extends SearchContext {
...searchParams.map(p => this.params[p] ? p + ':"' + this.params[p] + '"' : ''),
].filter(p => p.length > 0).join('');
return [queryParams, 'q=' + searchQueryParam].filter(q => q.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 {
@@ -108,12 +120,24 @@ export class GoogleSearchContext extends SearchContext {
}
}
next() {
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'] = (index + resultsPerPage).toString();
data['startIndex'] = Math.max(0, index + resultsPerPage * pageChange).toString();
return new GoogleSearchContext(this.search, data);
}

View File

@@ -9,6 +9,7 @@ export abstract class SearchContext {
this.params = params;
}
abstract generateQueryParams();
abstract next();
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

@@ -48,6 +48,10 @@ export class BookSearchResultDto {
@IsNotEmpty()
language: string;
@IsString()
@IsOptional()
mediaType: string | null;
@IsArray()
@IsString({ each: true })
categories: string[];

View File

@@ -1,18 +1,20 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, HttpException, Injectable } from '@nestjs/common';
import { BookSearchResultDto } from '../dto/book-search-result.dto';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom, map, timeout } from 'rxjs';
import { AxiosResponse } from '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=10&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))&q=';
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)
@@ -28,15 +30,31 @@ export class GoogleService {
return null;
}
const defaultQueryParams = 'langRestrict=en&printType=books&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))';
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();
console.log(defaultQueryParams, customQueryParams);
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;
})
)
);
}
@@ -47,11 +65,11 @@ export class GoogleService {
}
return response.data.items
//.filter(item => item.volumeInfo?.canonicalVolumeLink?.startsWith('https://play.google.com/store/books/details'))
.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,
@@ -60,18 +78,18 @@ export class GoogleService {
volume: item.volumeInfo.seriesInfo?.bookDisplayNumber ? parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber, 10) : undefined,
publisher: item.volumeInfo.publisher,
authors: item.volumeInfo.authors,
categories: item.volumeInfo.categories,
categories: item.volumeInfo.categories ?? [],
mediaType: null,
maturityRating: item.volumeInfo.maturityRating,
industryIdentifiers: item.volumeInfo.industryIdentifiers ? Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => ({ [i.type]: i.identifier }))) : [],
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: item.volumeInfo.imageLinks?.thumbnail,
thumbnail: secure && item.volumeInfo.imageLinks?.thumbnail ? item.volumeInfo.imageLinks.thumbnail.replaceAll('http://', 'https://') : item.volumeInfo.imageLinks?.thumbnail,
url: item.volumeInfo.canonicalVolumeLink,
provider: 'google'
}
let regex = this.getRegexByPublisher(result.publisher);
};
const regex = this.getRegexByPublisher(result.publisher);
const match = result.title.match(regex);
if (match?.groups) {
result.title = match.groups['title'].trim();
@@ -80,19 +98,41 @@ export class GoogleService {
}
}
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>.+?):?\sVolume\s(?<volume>\d+)/i;
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]+)\)/;
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+)/;
return /^(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)$/i;
}
}
}

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, UseGuards } from '@nestjs/common';
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ProvidersService } from './providers.service';
import { BookSearchInputDto } from './dto/book-search-input.dto';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { SimplifiedSearchContext } from './contexts/simplified-search-context';
@Controller('providers')
export class ProvidersController {
@@ -12,8 +12,10 @@ export class ProvidersController {
@UseGuards(JwtAccessGuard)
@Get('search')
async Search(
@Body() body: BookSearchInputDto,
@Query() context,
) {
return await this.providers.searchRaw(body.provider, body.query);
const simplified = new SimplifiedSearchContext(context);
const searchContext = simplified.toSearchContext();
return await this.providers.search(searchContext);
}
}

View File

@@ -1,8 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { SeriesDto } from './series.dto';
export class CreateSeriesSubscriptionDto extends SeriesDto {
@IsString()
@IsNotEmpty()
title: string;
}

View File

@@ -1,8 +1,13 @@
import { IsNotEmpty, IsString } from 'class-validator';
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

@@ -1,8 +1,12 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { SeriesSubscriptionDto } from './series-subscription.dto';
export class CreateSeriesSubscriptionJobDto extends SeriesSubscriptionDto {
export class SeriesSubscriptionJobDto extends SeriesSubscriptionDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
mediaType: string;
}

View File

@@ -1,5 +1,6 @@
import { UUID } from 'crypto';
import { Column, Entity, PrimaryColumn, Unique } from 'typeorm';
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { SeriesEntity } from './series.entity';
@Entity("series_subscriptions")
export class SeriesSubscriptionEntity {
@@ -14,4 +15,15 @@ export class SeriesSubscriptionEntity {
@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

@@ -1,6 +1,7 @@
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'])
@@ -14,6 +15,9 @@ export class SeriesEntity {
@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;
@@ -22,4 +26,7 @@ export class SeriesEntity {
@OneToMany(type => BookEntity, book => [book.provider, book.providerSeriesId])
volumes: BookEntity[];
@OneToMany(type => SeriesSubscriptionEntity, subscription => [subscription.provider, subscription.providerSeriesId])
subscriptions: SeriesSubscriptionEntity[];
}

View File

@@ -12,9 +12,9 @@ import { SeriesSubscriptionDto } from './dto/series-subscription.dto';
export class SeriesService {
constructor(
@InjectRepository(SeriesEntity)
private seriesRepository: Repository<SeriesEntity>,
private readonly seriesRepository: Repository<SeriesEntity>,
@InjectRepository(SeriesSubscriptionEntity)
private seriesSubscriptionRepository: Repository<SeriesSubscriptionEntity>,
private readonly seriesSubscriptionRepository: Repository<SeriesSubscriptionEntity>,
) { }
@@ -37,7 +37,7 @@ export class SeriesService {
async getSeries(series: SeriesDto) {
return await this.seriesRepository.findOne({
where: series
})
});
}
async getAllSeries() {
@@ -45,11 +45,15 @@ export class SeriesService {
}
async getSeriesSubscribedBy(userId: UUID) {
return await this.seriesSubscriptionRepository.find({
where: {
userId,
}
});
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) {

View File

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

View File

@@ -3,11 +3,11 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from './entities/users.entity';
import { LoginUserDto } from './dto/login-user.dto';
import { UUID } from 'crypto';
import { LoginDto } from 'src/auth/dto/login.dto';
class UserDto {
userId: string;
userId: UUID;
userLogin: string;
userName: string;
isAdmin: boolean;
@@ -32,15 +32,15 @@ export class UsersService {
}));
}
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(),
]
};

View File

@@ -0,0 +1,22 @@
import { Routes } from '@angular/router';
import { LoginFormComponent } from './login/login-form/login-form.component';
import { RegisterFormComponent } from './register/register-form/register-form.component';
import { AddNewPageComponent } from './library/add-new-page/add-new-page.component';
export const routes: Routes = [
{
path: 'login',
component: LoginFormComponent,
canActivate: [],
},
{
path: 'register',
component: RegisterFormComponent,
canActivate: [],
},
{
path: 'add/new',
component: AddNewPageComponent,
canActivate: [],
},
];

View File

@@ -0,0 +1,72 @@
.search-content {
display: flex;
flex-direction: column;
height: 100vh;
}
.results-box {
background: #5757576c;
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
}
.result-item {
padding: 10px;
}
.results-error {
text-align: center;
font-size: 16px;
color: red;
padding: 20px;
}
.results-end {
text-align: center;
font-size: 16px;
color: #ff8c00;
padding: 20px;
}
.results-error img,
.results-end img {
vertical-align: middle;
}
.filter-error {
filter: brightness(0) saturate(100%) invert(25%) sepia(67%) saturate(2736%) hue-rotate(340deg) brightness(110%) contrast(100%);
}
.filter-warning {
filter: brightness(0) saturate(100%) invert(52%) sepia(95%) saturate(2039%) hue-rotate(3deg) brightness(106%) contrast(102%);
}
.loading {
width: fit-content;
font-weight: bold;
font-family: monospace;
font-size: 25px;
align-self: center;
padding: 5px;
margin: 20px;
background: radial-gradient(circle closest-side, #000 94%, #0000) right/calc(200% - 1em) 100%;
animation: l24 1s infinite alternate linear;
}
.loading::before {
content: "Loading...";
line-height: 1em;
color: #0000;
background: inherit;
background-image: radial-gradient(circle closest-side, #fff 94%, #000);
-webkit-background-clip: text;
background-clip: text;
}
@keyframes l24 {
100% {
background-position: left
}
}

View File

@@ -0,0 +1,31 @@
<div #scrollbar
class="search-content">
<search-box (searchOutput)="search.next($event)"
(filtersOutput)="filters.next($event)" />
<div class="results-box"
(scroll)="onResultsScroll($event)">
@for (result of results; track $index) {
<media-search-item class="result-item"
[media]="result" />
}
@if (busy()) {
<div class="loading"></div>
}
@if (searchError() != null) {
<p class="results-error">
<img src="/icons/error_icon.svg"
alt="error icon"
class="filter-error" />
{{searchError()}}
</p>
}
@if (endOfResults()) {
<div class="results-end">
<img src="/icons/warning_icon.svg"
alt="warning icon"
class="filter-warning" />
No more results returned from the provider.
</div>
}
</div>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AddNewPageComponent } from './add-new-page.component';
describe('AddNewPageComponent', () => {
let component: AddNewPageComponent;
let fixture: ComponentFixture<AddNewPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AddNewPageComponent]
})
.compileComponents();
fixture = TestBed.createComponent(AddNewPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,201 @@
import { Component, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged, filter, map, scan, Subscription, tap, throttleTime } from 'rxjs';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
import { MediaSearchItemComponent } from '../media-search-item/media-search-item.component';
import { SearchBoxComponent } from "../search-box/search-box.component";
import { SearchContextDto } from '../../shared/dto/search-context.dto';
const DEFAULT_MAX_RESULTS = 10;
const PROVIDER_SETTINGS = {
google: {
FilterSearchParams: ['inauthor', 'inpublisher', 'intitle', 'isbn', 'subject'],
FilterNoUpdateParams: ['maxResults'],
}
}
@Component({
selector: 'add-new-page',
standalone: true,
imports: [
MediaSearchItemComponent,
ReactiveFormsModule,
SearchBoxComponent
],
templateUrl: './add-new-page.component.html',
styleUrl: './add-new-page.component.css'
})
export class AddNewPageComponent implements OnDestroy {
private readonly _http = inject(HttpClient);
private readonly _subscriptions: Subscription[] = [];
private readonly _zone = inject(NgZone);
@ViewChild('scrollbar') private readonly searchContentRef: ElementRef<Element> = {} as ElementRef;
search = new BehaviorSubject<string>('');
filters = new BehaviorSubject<SearchContextDto>(new SearchContextDto());
page = new BehaviorSubject<number>(0);
results: BookSearchResultDto[] = [];
resultsPerPage = signal<number>(10);
busy = signal<boolean>(false);
endOfResults = signal<boolean>(false);
searchError = signal<string | null>(null);
constructor() {
this._zone.runOutsideAngular(() => {
// Subscription for max results.
this._subscriptions.push(
this.filters.pipe(
map(filters => 'maxResults' in filters.values ? parseInt(filters.values['maxResults']) : DEFAULT_MAX_RESULTS)
).subscribe(maxResults => {
this.resultsPerPage.set(maxResults);
})
);
// Subscription for the search bar.
this._subscriptions.push(
combineLatest({
search: this.search.pipe(
filter(value => value != null),
),
filters: this.filters.pipe(
map(filters => ({ values: { ...filters.values } }))
),
page: this.page.pipe(
throttleTime(3000, undefined, { leading: false, trailing: true }),
),
}).pipe(
debounceTime(1000),
filter(entry => entry.search.length > 1 || this.isUsingSearchParamsInFilters(entry.filters)),
scan((acc, next) => {
// Different search or filters means resetting to page 0.
const searchChanged = acc.search != next.search && next.search && next.search.length > 1;
const filtersChanged = this.hasFiltersMismatched(acc.filters, next.filters);
if (searchChanged || filtersChanged) {
this.results = [];
return {
...next,
page: 0,
};
}
// Ignore further page searching if:
// - there are no more results;
// - user is still busy loading new pages;
// - only max results filter changed.
if (this.endOfResults() || this.busy() || acc.filters.values['maxResults'] != next.filters.values['maxResults']) {
return {
...next,
page: -1,
};
}
// Keep searching the same page until error stops.
if (this.searchError() != null) {
return acc;
}
// Next page.
return {
...next,
page: Math.min(acc.page + 1, next.page),
};
}),
filter(entry => entry.page >= 0),
distinctUntilChanged(),
).subscribe((entry) => {
this.busy.set(true);
this.endOfResults.set(false);
if (this.searchContentRef) {
this.searchContentRef.nativeElement.scrollTop = 0;
}
this._http.get('/api/providers/search',
{
params: {
...entry.filters.values,
provider: 'google',
search: entry.search!,
startIndex: entry.page * this.resultsPerPage(),
},
}
).subscribe({
next: (results: any) => {
[].push.apply(this.results, results);
if (results.length < this.resultsPerPage()) {
this.endOfResults.set(true);
}
this.searchError.set(null);
this.busy.set(false);
},
error: (err) => {
this.busy.set(false);
if (err instanceof HttpErrorResponse) {
if (err.status == 400) {
this.searchError.set('Something went wrong when Google received the request.');
} else if (err.status == 401) {
this.searchError.set('Unauthorized. Refresh the page to login again.');
} else if (err.status == 429) {
this.searchError.set('Too many requests. Try again in a minute.');
} else {
this.searchError.set(err.name + ': ' + err.message);
}
}
}
});
})
);
});
}
ngOnDestroy(): void {
this._subscriptions.forEach(s => s.unsubscribe());
}
onResultsScroll(event: any): void {
const scroll = event.target;
const limit = scroll.scrollHeight - scroll.clientHeight;
// Prevent page changes when:
// - new search is happening (emptied results);
// - still scrolling through current content.
if (scroll.scrollTop == 0 || scroll.scrollTop < limit - 25) {
return;
}
this.page.next(this.page.getValue() + 1);
}
private hasFiltersMismatched(prev: SearchContextDto, next: SearchContextDto) {
if (prev == next) {
return false;
}
for (let key in prev.values) {
if (PROVIDER_SETTINGS.google.FilterNoUpdateParams.includes(key))
continue;
if (!(key in next.values))
return true;
if (prev.values[key] != next.values[key])
return true;
}
for (let key in next.values) {
if (!PROVIDER_SETTINGS.google.FilterNoUpdateParams.includes(key) && !(key in prev.values))
return true;
}
return false;
}
private isUsingSearchParamsInFilters(context: SearchContextDto) {
if (!context)
return false;
const keys = Object.keys(context.values);
return keys.some(key => PROVIDER_SETTINGS.google.FilterSearchParams.includes(key));
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
declarations: [],
imports: [
CommonModule
]
})
export class LibraryModule { }

View File

@@ -0,0 +1,81 @@
.modal {
background-color: #EEE;
}
.header {
display: flex;
padding: 8px 15px 2px;
}
.title {
display: inline;
--webkit-box-decoration-break: clone;
box-decoration-break: clone;
margin: 0;
}
.volume {
background-color: hsl(0, 0%, 84%);
display: inline;
margin-left: 10px;
padding: 3px;
border-radius: 4px;
}
.year {
color: grey;
margin-left: 10px;
}
.close-button {
max-width: 24px;
max-height: 24px;
align-self: center;
margin-left: auto;
}
.close-button>img {
max-width: 24px;
max-height: 24px;
filter: brightness(0) saturate(100%) invert(42%) sepia(75%) saturate(5087%) hue-rotate(340deg) brightness(101%) contrast(109%);
}
.result-item {
padding: 10px 25px 0;
border-radius: 15px;
display: flex;
flex-direction: row;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.result-image {
align-self: start;
object-fit: scale-down;
}
.result-info {
margin-left: 10px;
}
.body {
margin: 5px 10px;
}
.footer {
display: flex;
text-align: center;
padding: 10px 20px 15px;
justify-content: end;
}
button {
padding: 10px;
border-radius: 5px;
border: 0;
cursor: pointer;
}
.subscribe {
background-color: aquamarine;
}

View File

@@ -0,0 +1,30 @@
<div class="modal">
<div class="header">
<div class="subheader">
<h2 class="title">{{data.title}}</h2>
@if (!isSeries && data.volume != null) {
<label class="volume">volume {{data.volume}}</label>
}
<label class="year">({{data.publishedAt.substring(0, 4)}})</label>
</div>
<div class="close-button"
(click)="dialogRef.close()">
<img src="/icons/close_icon.svg"
alt="close button" />
</div>
</div>
<hr />
<div class="result-item">
<img class="result-image"
[src]="data.thumbnail" />
<div class="result-info">
<p class="body description">{{data.desc}}</p>
</div>
</div>
<hr />
<div class="footer">
<button type="submit"
class="subscribe"
(click)="subscribe()">{{isSeries ? 'Subscribe' : 'Save'}}</button>
</div>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MediaItemModalComponent } from './media-item-modal.component';
describe('MediaItemModalComponent', () => {
let component: MediaItemModalComponent;
let fixture: ComponentFixture<MediaItemModalComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MediaItemModalComponent]
})
.compileComponents();
fixture = TestBed.createComponent(MediaItemModalComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,46 @@
import { Component, inject, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-media-item-modal',
standalone: true,
imports: [],
templateUrl: './media-item-modal.component.html',
styleUrl: './media-item-modal.component.css'
})
export class MediaItemModalComponent implements OnInit {
private readonly _http = inject(HttpClient);
readonly data = inject<BookSearchResultDto>(MAT_DIALOG_DATA);
readonly dialogRef = inject(MatDialogRef<MediaItemModalComponent>);
isSeries: boolean = false;
ngOnInit(): void {
this.isSeries = this.data.providerSeriesId != null;
}
subscribe() {
console.log('data for subscribe:', this.data);
if (this.isSeries) {
this._http.post('/api/library/series', this.data)
.subscribe({
next: response => {
console.log('subscribe series:', response);
},
error: err => console.log('error on subscribing series:', err)
});
} else {
this._http.post('/api/library/books', this.data)
.subscribe({
next: response => {
console.log('save book:', response);
},
error: err => console.log('error on saving book:', err)
});
}
}
}

View File

@@ -0,0 +1,69 @@
.result-item {
background-color: #EEE;
padding: 15px;
border-radius: 15px;
display: flex;
flex-direction: row;
cursor: pointer;
}
.result-image {
align-self: start;
object-fit: scale-down;
}
.result-info {
margin-left: 10px;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.title {
display: inline;
}
.volume {
background-color: hsl(0, 0%, 84%);
display: inline;
margin-left: 10px;
padding: 3px;
border-radius: 4px;
}
.tags {
display: inline-flex;
flex-wrap: wrap;
margin-bottom: 15px;
}
.tag {
padding: 0 5px;
margin: 3px;
background-color: rgb(199, 199, 199);
border-radius: 4px;
text-wrap: nowrap;
}
.body {
margin: 5px 10px;
}
.description {
overflow: hidden;
display: -webkit-box;
line-clamp: 4;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.spacing {
flex: 1;
}
.footer {
width: 100%;
height: 100%;
text-align: right;
}

View File

@@ -0,0 +1,21 @@
<div class="result-item"
(click)="open()">
<img class="result-image"
[src]="media().thumbnail" />
<div class="result-info">
<div class="header">
<h2 class="title">{{media().title}}</h2>
@if (media().volume != null) {
<label class="volume">volume {{media().volume}}</label>
}
</div>
<div class="subheader tags">
@for (tag of tags(); track $index) {
<label class="tag">{{tag}}</label>
}
</div>
<p class="body description">{{media().desc}}</p>
<span class="spacing"></span>
<p class="footer">Metadata provided by {{provider()}}</p>
</div>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MediaSearchItemComponent } from './media-search-item.component';
describe('MediaSearchItemComponent', () => {
let component: MediaSearchItemComponent;
let fixture: ComponentFixture<MediaSearchItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MediaSearchItemComponent]
})
.compileComponents();
fixture = TestBed.createComponent(MediaSearchItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,53 @@
import { Component, computed, inject, input } from '@angular/core';
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MediaItemModalComponent } from '../media-item-modal/media-item-modal.component';
@Component({
selector: 'media-search-item',
standalone: true,
imports: [
MatDialogModule,
],
templateUrl: './media-search-item.component.html',
styleUrl: './media-search-item.component.css'
})
export class MediaSearchItemComponent {
private readonly _dialog = inject(MatDialog);
media = input.required<BookSearchResultDto>();
tags = computed(() => {
const tags = [];
if (this.media().language)
tags.push(this.media().language);
if (this.media().publisher)
tags.push(this.media().publisher);
if (this.media().authors)
tags.push.apply(tags, this.media().authors.map(author => 'author: ' + author));
if (this.media().categories)
tags.push.apply(tags, this.media().categories);
if (this.media().maturityRating.replaceAll('_', ' '))
tags.push(this.media().maturityRating);
if (this.media().publishedAt) {
tags.push(new Date(this.media().publishedAt).getFullYear());
}
return tags;
});
provider = computed(() => {
const value = this.media().provider;
return value.split(' ')
.map(s => s[0].toUpperCase() + s.substring(1))
.join(' ')
.split('-')
.map(s => s[0].toUpperCase() + s.substring(1))
.join('-');
});
open(): void {
this._dialog.open(MediaItemModalComponent, {
data: { ...this.media() }
});
}
}

View File

@@ -0,0 +1,108 @@
.search-content {
display: flex;
flex-direction: column;
height: 100vh;
}
.search-bar {
display: flex;
padding: 20px;
flex-direction: row;
width: calc(100% - 40px);
}
.left-side,
.center-side,
.right-side {
align-content: center;
height: 40px;
border-top: 1px;
border-bottom: 1px;
border: 1px gray solid;
}
.icon-wrapper {
width: 40px;
height: 40px;
}
.left-side {
border: 1px gray solid;
border-right: 0;
border-radius: 3px 0 0 3px;
text-align: center;
}
.center-side {
flex-grow: 1;
}
.right-side {
border: 1px gray solid;
border-left: 0;
border-radius: 0 3px 3px 0;
text-align: center;
}
.search-box {
display: flex;
}
input {
flex-grow: 1;
outline: none;
font-size: 20px;
border: 0;
padding: 0 15px;
}
input:focus {
box-sizing: border-box;
box-shadow: 0 0 4px 3px rgba(31, 128, 255, 0.5);
}
.icon-wrapper>img, .icon-button>img {
display: inline;
vertical-align: middle;
filter: brightness(0) saturate(100%) invert(44%) sepia(0%) saturate(167%) hue-rotate(154deg) brightness(90%) contrast(87%);
}
.icon-button:hover {
cursor: pointer;
}
.icon-enabled {
background-color: rgb(185, 185, 185);
}
.collapsed {
display: none;
}
.filters-box {
margin: 0 60px 10px;
font-size: 15px;
}
.checkbox-wrapper, .select-wrapper, .text-wrapper {
display: inline-flex;
height: 20px;
align-items: center;
margin: 10px;
}
.checkbox-wrapper input, .select-wrapper select, .text-wrapper input {
display: inline;
height: 20px;
margin: 5px;
}
.text-wrapper input {
font-size: 13px;
outline: 1px solid;
}
.checkbox-wrapper label, .select-wrapper label, .text-wrapper label {
display: inline;
white-space: nowrap;
}

View File

@@ -0,0 +1,72 @@
<div class="search-bar">
<div class="icon-wrapper left-side">
<img src="/icons/search_icon.svg"
alt="search icon" />
</div>
<div class="icon-wrapper icon-button left-side"
[class.icon-enabled]="filtersEnabled.value">
<img src="/icons/category_search_icon.svg"
alt="advanced search icon"
(click)="filtersEnabled.setValue(!filtersEnabled.value)" />
</div>
<div class="search-box center-side">
<input type="text"
placeholder="eg. Lord of the Rings"
[formControl]="search" />
</div>
<div class="icon-wrapper icon-button right-side">
<img src="/icons/close_icon.svg"
alt="clear text button"
(click)="search.setValue('')" />
</div>
</div>
<div class="filters-box"
[class.collapsed]="!filtersEnabled.value">
<div class="select-wrapper">
<label for="languageSelect">Language</label>
<select #languageSelect
name="languageSelect"
(change)="updateFilters('langRestrict', $event.target)">
<option value="">All</option>
@for (language of provider.languages | keyvalue; track language.key) {
<option [value]="language.key">{{language.value}}</option>
}
</select>
</div>
<div class="select-wrapper">
<label for="orderBySelect">Order By</label>
<select #orderBySelect
name="orderBySelect"
(change)="updateFilters('orderBy', $event.target)">
<option value="relevance">Relevance</option>
<option value="newest">Newest</option>
</select>
</div>
<div class="select-wrapper">
<label for="resultsSizeSelect">Results Size</label>
<select #resultsSizeSelect
name="resultsSizeSelect"
(change)="updateFilters('maxResults', $event.target)">
<option value="10">10</option>
<option value="20">20</option>
<option value="30">30</option>
<option value="40">40</option>
</select>
</div>
<div class="text-wrapper">
<label for="authorInput">Author</label>
<input #authorInput
name="authorInput"
type="text"
placeholder="J. R. R. Tolkien"
[formControl]="author" />
</div>
<div class="text-wrapper">
<label for="isbnInput">ISBN</label>
<input #isbnInput
name="isbnInput"
type="text"
placeholder="ISBN-10 or ISBN-13"
[formControl]="isbn" />
</div>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchBoxComponent } from './search-box.component';
describe('SearchBoxComponent', () => {
let component: SearchBoxComponent;
let fixture: ComponentFixture<SearchBoxComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SearchBoxComponent]
})
.compileComponents();
fixture = TestBed.createComponent(SearchBoxComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,98 @@
import { AfterViewInit, Component, ElementRef, inject, NgZone, OnDestroy, output, ViewChild } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { SearchContextDto } from '../../shared/dto/search-context.dto';
import { filter, map, Subscription } from 'rxjs';
import { ConfigService } from '../../services/config.service';
import { CommonModule } from '@angular/common';
@Component({
selector: 'search-box',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
],
templateUrl: './search-box.component.html',
styleUrl: './search-box.component.css'
})
export class SearchBoxComponent implements AfterViewInit, OnDestroy {
private readonly _subscriptions: Subscription[] = [];
private readonly _zone = inject(NgZone);
@ViewChild('languageSelect') private readonly languageRef: ElementRef<HTMLSelectElement> = {} as ElementRef;
@ViewChild('orderBySelect') private readonly orderByRef: ElementRef<HTMLSelectElement> = {} as ElementRef;
@ViewChild('resultsSizeSelect') private readonly resultsSizeRef: ElementRef<HTMLSelectElement> = {} as ElementRef;
@ViewChild('authorInput') private readonly authorRef: ElementRef<HTMLInputElement> = {} as ElementRef;
@ViewChild('isbnInput') private readonly isbnRef: ElementRef<HTMLInputElement> = {} as ElementRef;
config = inject(ConfigService).config;
filtersEnabled = new FormControl<boolean>(false);
search = new FormControl<string>('');
searchOutput = output<string>();
filters = new SearchContextDto();
filtersOutput = output<SearchContextDto>();
isbn = new FormControl<string>('');
author = new FormControl<string>('');
constructor() {
this._zone.runOutsideAngular(() => {
this._subscriptions.push(
this.search.valueChanges.pipe(
filter(value => value != null),
map(value => value!.trim()),
filter(value => value.length > 0),
).subscribe((value) => this.searchOutput.emit(value!))
);
this._subscriptions.push(
this.author.valueChanges.pipe(
filter(value => value != null),
map(value => value!.trim()),
filter(value => value.length > 0),
).subscribe((value) => this.updateFilters('inauthor', { value: value }))
);
this._subscriptions.push(
this.isbn.valueChanges.pipe(
filter(value => value != null),
map(value => value!.trim()),
map(value => value.length == 10 || value.length >= 13 && value.length <= 15 ? value : ''),
filter(value => value.length > 0),
).subscribe((value) => this.updateFilters('isbn', { value: value }))
);
});
}
ngAfterViewInit() {
if (this.languageRef && this.languageRef.nativeElement.value)
this.updateFilters('langRestrict', this.languageRef.nativeElement);
if (this.orderByRef && this.orderByRef.nativeElement.value)
this.updateFilters('orderBy', this.orderByRef.nativeElement);
if (this.resultsSizeRef && this.resultsSizeRef.nativeElement.value)
this.updateFilters('maxResults', this.resultsSizeRef.nativeElement);
if (this.authorRef && this.authorRef.nativeElement.value)
this.updateFilters('inauthor', this.authorRef.nativeElement);
if (this.isbnRef && this.isbnRef.nativeElement.value)
this.updateFilters('isbn', this.isbnRef.nativeElement);
}
ngOnDestroy(): void {
this._subscriptions.forEach(s => s.unsubscribe());
}
get provider() {
switch (this.config.providers.default) {
case 'google': return this.config.providers.google;
default: return this.config.providers.google;
}
}
updateFilters(key: string, value: any) {
if (!value) {
delete this.filters.values[key];
} else {
this.filters.values[key] = value.value;
}
this.filtersOutput.emit(this.filters);
}
}

View File

@@ -0,0 +1,111 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.form {
width: 500px;
height: 100%;
border: black solid 2px;
border-radius: 15px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin: 0 auto;
}
h1 {
display: block;
}
.messages {
width: 100%;
display: block;
text-align: center;
margin: 0 auto;
}
input {
border: none;
background-color: transparent;
width: 100%;
height: 100%;
font-size: 16px;
}
input:focus {
outline: none;
}
.input-wrapper {
margin: 20px;
padding: 5px;
border: gray solid 1px;
border-radius: 15px;
background-color: white;
display: block;
width: 400px;
height: 50px;
}
.input-wrapper:focus {
outline: 5px solid blue;
}
.checkbox-wrapper {
display: inline-flex;
height: 20px;
align-items: center;
margin: 0 auto;
}
.checkbox-wrapper input {
display: inline;
height: 20px;
padding: 5px;
margin: 5px;
}
.checkbox-wrapper label {
display: inline;
white-space: nowrap;
}
.actions {
width: 100%;
}
button {
width: calc(100% - 30px);
height: 50px;
margin: 10px auto;
border: 1px gray solid;
border-radius: 15px;
display: block;
background-color: white;
font-size: 16px;
}
button:hover {
background-color: #FAFAFA;
cursor: pointer;
}
button:disabled {
background-color: white;
cursor: auto;
}
.register-link {
margin: 10px 0;
}
.register-link a {
text-decoration: none;
}
.register-link a:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,38 @@
<div class="form">
<h1>Login</h1>
<div class="messages">
<notice [message]="errorMessage"
[level]="'error'"
[visible]="errorMessage.length > 0" />
</div>
<div class="fields">
<div class="input-wrapper">
<input type="text"
placeholder="Username"
[formControl]="username" />
</div>
<div class="input-wrapper">
<input type="password"
placeholder="Password"
[formControl]="password" />
</div>
<div class="checkbox-wrapper">
<input type="checkbox"
id="rememberme"
[formControl]="rememberMe">
<label for="rememberme">Remember me?</label>
</div>
</div>
<div class="actions">
<button type="submit"
(click)="login()"
[disabled]="!forms.valid || waitForResponse">Login</button>
</div>
@if (config.features.registration) {
<div class="register-link">
Don't have an account?
<a href="/register"
(click)="register($event)">Register here.</a>
</div>
}
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoginFormComponent } from './login-form.component';
describe('LoginFormComponent', () => {
let component: LoginFormComponent;
let fixture: ComponentFixture<LoginFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LoginFormComponent]
})
.compileComponents();
fixture = TestBed.createComponent(LoginFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,81 @@
import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { NoticeComponent } from "../../shared/notice/notice.component";
import { Router } from '@angular/router';
import { ConfigService } from '../../services/config.service';
import { AuthService } from '../../services/auth/auth.service';
import { LoadingService } from '../../services/loading.service';
import { RedirectionService } from '../../services/redirection.service';
@Component({
selector: 'login-form',
standalone: true,
imports: [
ReactiveFormsModule,
NoticeComponent
],
templateUrl: './login-form.component.html',
styleUrl: './login-form.component.css'
})
export class LoginFormComponent {
private readonly _auth = inject(AuthService);
private readonly _loading = inject(LoadingService);
private readonly _http = inject(HttpClient);
private readonly _redirect = inject(RedirectionService);
private readonly _router = inject(Router);
readonly config = inject(ConfigService).config;
readonly username = new FormControl('', [Validators.required]);
readonly password = new FormControl('', [Validators.required]);
readonly rememberMe = new FormControl(false);
readonly forms = new FormGroup({
username: this.username,
password: this.password,
});
waitForResponse: boolean = false;
errorMessage: string = '';
login() {
if (this.waitForResponse) {
return;
}
this.waitForResponse = true;
this.errorMessage = '';
if (!this.forms.valid) {
this.waitForResponse = false;
return;
}
this._http.post('/api/auth/login', {
user_login: this.username.value,
password: this.password.value,
remember_me: this.rememberMe.value,
}).subscribe({
next: async (response: any) => {
this._auth.update(true)
.subscribe(async () => await this._redirect.redirect(null));
},
error: (err) => this.handleLoginErrors(err),
complete: () => this.waitForResponse = false
});
}
handleLoginErrors(err: any) {
if (err.status == 400 || err.status == 401) {
this.errorMessage = 'Invalid credentials.';
} else if (err.status == 403) {
this.errorMessage = 'Something went wrong.';
} else {
this.errorMessage = 'Something went wrong: ' + err.error.statusCode + ' ' + err.error.message;
}
this.waitForResponse = false;
}
async register(event: any) {
event.preventDefault();
await this._router.navigate(['register']);
}
}

View File

@@ -0,0 +1,10 @@
import { NgModule } from '@angular/core';
@NgModule({
declarations: [],
imports: [
],
providers: []
})
export class LoginModule { }

View File

@@ -0,0 +1,101 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.form {
width: 500px;
height: 100%;
border: black solid 2px;
border-radius: 15px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin: 0 auto;
}
h1 {
display: block;
}
.messages {
width: 100%;
display: block;
text-align: center;
margin: 0 auto;
}
input {
border: none;
background-color: transparent;
width: 100%;
height: 100%;
font-size: 16px;
}
input:focus {
outline: none;
}
.input-wrapper {
margin: 20px;
padding: 5px;
border: gray solid 1px;
border-radius: 15px;
background-color: white;
display: block;
width: 400px;
height: 50px;
}
.input-wrapper:focus {
outline: 5px solid blue;
}
.actions {
width: 100%;
}
button {
width: calc(100% - 30px);
height: 50px;
margin: 10px auto;
border: 1px gray solid;
border-radius: 15px;
display: block;
background-color: white;
font-size: 16px;
}
button:hover {
background-color: #FAFAFA;
cursor: pointer;
}
button:disabled {
background-color: white;
cursor: auto;
}
.register-link {
margin: 10px 0;
}
.register-link a {
text-decoration: none;
}
.register-link a:hover {
text-decoration: underline;
}
.disabled {
color: red;
}
.center {
font-size: 16;
text-align: center;
}

View File

@@ -0,0 +1,45 @@
@if (config.features.registration) {
<div class="form">
<h1>Register</h1>
<div class="messages">
<notice [message]="errorMessage"
[level]="'error'"
[visible]="errorMessage.length > 0" />
</div>
<div class="fields">
<div class="input-wrapper">
<input type="text"
placeholder="Name"
[formControl]="name" />
</div>
<div class="input-wrapper">
<input type="text"
placeholder="Username"
[formControl]="username" />
</div>
<div class="input-wrapper">
<input type="password"
placeholder="Password"
[formControl]="password" />
</div>
<div class="input-wrapper">
<input type="password"
placeholder="Password Confirm"
[formControl]="passwordConfirmation" />
</div>
</div>
<div class="actions">
<button type="submit"
(click)="register()"
[disabled]="!forms.valid || waitForResponse">Register</button>
</div>
<div class="register-link">
Already have an account?
<a href="/login"
(click)="login($event)">Login here.</a>
</div>
</div>
} @else {
<p class="center disabled">Registrations have been disabled.</p>
<p class="center">You will be redirected to login in 3 seconds.</p>
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegisterFormComponent } from './register-form.component';
describe('RegisterFormComponent', () => {
let component: RegisterFormComponent;
let fixture: ComponentFixture<RegisterFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RegisterFormComponent]
})
.compileComponents();
fixture = TestBed.createComponent(RegisterFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,92 @@
import { HttpClient } from '@angular/common/http';
import { Component, inject, OnInit } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { NoticeComponent } from "../../shared/notice/notice.component";
import { Router } from '@angular/router';
import { ConfigService } from '../../services/config.service';
import { AuthService } from '../../services/auth/auth.service';
import { LoadingService } from '../../services/loading.service';
import { RedirectionService } from '../../services/redirection.service';
@Component({
selector: 'register-form',
standalone: true,
imports: [
NoticeComponent,
ReactiveFormsModule,
],
templateUrl: './register-form.component.html',
styleUrl: './register-form.component.css'
})
export class RegisterFormComponent implements OnInit {
private readonly _auth = inject(AuthService);
private readonly _loading = inject(LoadingService);
private readonly _http = inject(HttpClient);
private readonly _redirect = inject(RedirectionService);
private readonly _router = inject(Router);
readonly config = inject(ConfigService).config;
readonly name = new FormControl('', [Validators.required]);
readonly username = new FormControl('', [Validators.required]);
readonly password = new FormControl('', [Validators.required]);
readonly passwordConfirmation = new FormControl('', [Validators.required]);
readonly forms = new FormGroup({
name: this.name,
username: this.username,
password: this.password,
passwordConfirmation: this.passwordConfirmation
});
waitForResponse: boolean = false;
errorMessage: string = '';
ngOnInit(): void {
}
register() {
if (this.waitForResponse) {
return;
}
this.waitForResponse = true;
this.errorMessage = '';
if (!this.forms.valid) {
this.waitForResponse = false;
return;
}
if (this.password.value != this.passwordConfirmation.value) {
this.errorMessage = 'Password confirmation does not match original password.';
return;
}
this._http.post('/api/auth/register', {
user_login: this.name.value,
user_name: this.username.value,
password: this.password.value,
}).subscribe({
next: async (response: any) => {
this._auth.update(true)
.subscribe(async () => await this._redirect.redirect(null));
},
error: (err) => this.handleRegistrationErrors(err),
complete: () => this.waitForResponse = false
});
}
handleRegistrationErrors(err: any) {
if (err.status == 401) {
this.errorMessage = 'Invalid credentials.';
} else if (err.status == 400 || err.status == 403) {
this.errorMessage = 'Something went wrong.';
} else {
this.errorMessage = 'Something went wrong: ' + err.error.statusCode + ' ' + err.error.message;
}
this.waitForResponse = false;
}
async login(event: any) {
event.preventDefault();
await this._router.navigate(['login']);
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
declarations: [],
imports: [
CommonModule
]
})
export class RegisterModule { }

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AuthService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,70 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { of } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly _http = inject(HttpClient);
private authenticated = false;
private userId: string | null = null;
private username: string | null = null;
private lastValidationCheck: number = 0;
getUserId() {
return this.userId;
}
getUsername() {
return this.username;
}
isLoggedIn() {
return this.authenticated;
}
renew() {
return this._http.patch('/api/auth/login', {});
}
update(force: boolean = false) {
if (!force && this.lastValidationCheck && this.lastValidationCheck + 90000 < Date.now()) {
return of(this.authenticated);
}
this.lastValidationCheck = Date.now();
const observable = this._http.post('/api/auth/validate', {})
observable.subscribe({
next: (response: any) => {
if (response.validation === null) {
this.renew().subscribe({
next: (res: any) => {
this.authenticated = true;
this.userId = res.userId;
this.username = res.username;
},
error: (err) => {
this.authenticated = false;
this.userId = null;
this.username = null;
}
});
return;
}
this.authenticated = response.validation;
this.userId = response.userId;
this.username = response.username;
},
error: (err) => {
this.authenticated = false;
this.userId = null;
this.username = null;
}
});
return observable;
}
}

Some files were not shown because too many files have changed in this diff Show More