Compare commits

...

33 Commits

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

View File

@@ -11,6 +11,8 @@ DROP TABLE IF EXISTS book_origins;
DROP TABLE IF EXISTS refresh_tokens; DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS series_subscriptions;
DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS books; DROP TABLE IF EXISTS books;
@@ -20,58 +22,64 @@ DROP TABLE IF EXISTS series;
CREATE TABLE CREATE TABLE
series ( series (
series_id uuid DEFAULT gen_random_uuid (), series_id uuid DEFAULT gen_random_uuid (),
-- 3rd party used to fetch the data for this series. -- 3rd party id for this series.
provider varchar(6) NOT NULL,
provider_series_id text, provider_series_id text,
series_title text NOT NULL, series_title text NOT NULL,
date_added timestamp default NULL, media_type text,
PRIMARY KEY (series_id) -- 3rd party used to fetch the data for this series.
provider varchar(12) NOT NULL,
added_at timestamp default NULL,
PRIMARY KEY (series_id),
UNIQUE (provider, provider_series_id)
); );
ALTER TABLE series ALTER TABLE series
ALTER COLUMN date_added ALTER COLUMN added_at
SET DEFAULT now (); SET DEFAULT now ();
CREATE INDEX series_series_title_idx ON series USING HASH (series_title); CREATE INDEX series_series_title_idx ON series (series_title);
CREATE TABLE CREATE TABLE
books ( books (
book_id uuid DEFAULT gen_random_uuid (), book_id uuid DEFAULT gen_random_uuid (),
series_id uuid, -- 3rd party id for this series if applicable.
provider_series_id text,
-- 3rd party id for this book.
provider_book_id text NOT NULL, provider_book_id text NOT NULL,
isbn varchar(16),
book_title text NOT NULL, book_title text NOT NULL,
book_desc text NOT NULL, book_desc text,
book_volume integer, book_volume integer,
date_released timestamp default NULL, -- 3rd party used to fetch the data for this book.
date_added timestamp default NULL, provider varchar(12) NOT NULL,
published_at timestamp default NULL,
added_at timestamp default NULL,
PRIMARY KEY (book_id), PRIMARY KEY (book_id),
FOREIGN KEY (series_id) REFERENCES series (series_id) FOREIGN KEY (provider, provider_series_id) REFERENCES series (provider, provider_series_id) ON DELETE CASCADE,
UNIQUE NULLS NOT DISTINCT (provider_series_id, provider_book_id, book_volume)
); );
ALTER TABLE books ALTER TABLE books
ALTER COLUMN date_released ALTER COLUMN added_at
SET DEFAULT now (); SET DEFAULT now ();
ALTER TABLE books -- CREATE INDEX books_series_id_idx ON books (series_id);
ALTER COLUMN date_added CREATE INDEX books_provider_provider_series_id_idx ON books (provider, provider_series_id);
SET DEFAULT now ();
CREATE INDEX books_series_id_idx ON books USING HASH (series_id); -- CREATE INDEX books_isbn_idx ON books USING HASH (isbn);
CREATE INDEX books_book_title_idx ON books (book_title);
CREATE INDEX books_isbn_idx ON books USING HASH (isbn);
CREATE INDEX books_book_title_idx ON books USING HASH (book_title);
CREATE TABLE CREATE TABLE
book_origins ( book_origins (
book_id uuid, book_origin_id uuid DEFAULT gen_random_uuid (),
origin_type varchar(8), book_id uuid NOT NULL,
origin_type integer,
origin_value text, origin_value text,
PRIMARY KEY (book_id, origin_type, origin_value) PRIMARY KEY (book_origin_id),
FOREIGN KEY (book_id) REFERENCES books (book_id) ON DELETE CASCADE,
UNIQUE (book_id, origin_type, origin_value)
); );
CREATE INDEX book_origins_book_id_idx ON book_origins USING HASH (book_id); CREATE INDEX book_origins_book_id_idx ON book_origins (book_id);
CREATE INDEX book_origins_type_value_idx ON book_origins (origin_type, origin_value); CREATE INDEX book_origins_type_value_idx ON book_origins (origin_type, origin_value);
@@ -83,59 +91,68 @@ CREATE TABLE
password text NOT NULL, password text NOT NULL,
salt bigint NOT NULL, salt bigint NOT NULL,
is_admin boolean NOT NULL, is_admin boolean NOT NULL,
date_joined timestamp default NULL, joined_at timestamp default NULL,
PRIMARY KEY (user_id), PRIMARY KEY (user_id),
UNIQUE (user_login) UNIQUE (user_login)
); );
ALTER TABLE users ALTER TABLE users
ALTER COLUMN date_joined ALTER COLUMN joined_at
SET DEFAULT now (); SET DEFAULT now ();
CREATE INDEX users_user_login_idx ON users USING HASH (user_login);
CREATE TABLE CREATE TABLE
refresh_tokens ( refresh_tokens (
user_id uuid NOT NULL, user_id uuid NOT NULL,
refresh_token_hash text NOT NULL, refresh_token_hash text NOT NULL,
exp timestamp NOT NULL, exp timestamp NOT NULL,
PRIMARY KEY (user_id, refresh_token_hash), PRIMARY KEY (user_id, refresh_token_hash),
FOREIGN KEY (user_id) REFERENCES users (user_id) FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE
); );
CREATE TABLE CREATE TABLE
book_statuses ( book_statuses (
user_id uuid, user_id uuid,
book_id uuid, book_id uuid,
state varchar(12), state smallint,
date_added timestamp default NULL, added_at timestamp default NULL,
date_modified timestamp default NULL, modified_at timestamp default NULL,
PRIMARY KEY (user_id, book_id), PRIMARY KEY (user_id, book_id),
FOREIGN KEY (user_id) REFERENCES users (user_id), FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE,
FOREIGN KEY (book_id) REFERENCES books (book_id) FOREIGN KEY (book_id) REFERENCES books (book_id) ON DELETE CASCADE
); );
ALTER TABLE book_statuses ALTER TABLE book_statuses
ALTER COLUMN date_added ALTER COLUMN added_at
SET DEFAULT now (); SET DEFAULT now ();
ALTER TABLE book_statuses CREATE INDEX book_statuses_user_id_login_idx ON users (user_id);
ALTER COLUMN date_modified
SET DEFAULT now ();
CREATE INDEX book_statuses_user_id_login_idx ON users USING HASH (user_id); CREATE TABLE
series_subscriptions (
user_id uuid,
provider varchar(12) NOT NULL,
provider_series_id text,
added_at timestamp default NULL,
PRIMARY KEY (user_id, provider, provider_series_id),
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE,
FOREIGN KEY (provider, provider_series_id) REFERENCES series (provider, provider_series_id) ON DELETE CASCADE
);
ALTER TABLE series_subscriptions
ALTER COLUMN added_at
SET DEFAULT now ();
CREATE TABLE CREATE TABLE
api_keys ( api_keys (
user_id uuid, user_id uuid,
api_key char(64), api_key char(64),
date_added timestamp default NULL, added_at timestamp default NULL,
PRIMARY KEY (user_id, api_key), PRIMARY KEY (user_id, api_key),
FOREIGN KEY (user_id) REFERENCES users (user_id) FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE
); );
ALTER TABLE api_keys ALTER TABLE api_keys
ALTER COLUMN date_added ALTER COLUMN added_at
SET DEFAULT now (); SET DEFAULT now ();
CREATE INDEX api_keys_api_key_idx ON api_keys USING HASH (api_key); CREATE INDEX api_keys_api_key_idx ON api_keys (api_key);

View File

@@ -0,0 +1,21 @@
{
"features": {
"registration": false
},
"providers": {
"default": "google",
"google": {
"name": "Google",
"filters": {},
"languages": {
"zh": "Chinese",
"nl": "Dutch",
"en": "English",
"fr": "Francais",
"ko": "Korean",
"ja": "Japanese",
"es": "Spanish"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.0",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.0", "@nestjs/config": "^4.0.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
@@ -28,14 +30,18 @@
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"axios": "^1.7.9",
"bullmq": "^5.41.7",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"moment": "^2.30.1", "moment": "^2.30.1",
"nestjs-pino": "^4.3.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"pino-http": "^10.4.0",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",

View File

@@ -8,10 +8,16 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { DatabaseOptions } from './database-config/database.options'; import { DatabaseOptions } from './database-config/database.options';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { UserEntity } from './users/users.entity'; import { UserEntity } from './users/entities/users.entity';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { LoggerModule } from 'nestjs-pino'; 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({ @Module({
imports: [ imports: [
@@ -20,6 +26,17 @@ import { serialize_token, serialize_user_short, serialize_user_long, serialize_r
imports: [ConfigModule], imports: [ConfigModule],
useClass: DatabaseOptions useClass: DatabaseOptions
}), }),
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: {
host: config.get('REDIS_HOST') ?? 'localhost',
port: config.get('REDIS_PORT') ?? 6379,
password: config.get('REDIS_PASSWORD'),
}
})
}),
TypeOrmModule.forFeature([UserEntity]), TypeOrmModule.forFeature([UserEntity]),
UsersModule, UsersModule,
AuthModule, AuthModule,
@@ -35,12 +52,14 @@ import { serialize_token, serialize_user_short, serialize_user_long, serialize_r
user: value => serialize_user_long(value), user: value => serialize_user_long(value),
access_token: value => serialize_token(value), access_token: value => serialize_token(value),
refresh_token: value => serialize_token(value), refresh_token: value => serialize_token(value),
job: value => serialize_job(value),
req: value => serialize_req(value), req: value => serialize_req(value),
res: value => serialize_res(value), res: value => serialize_res(value),
} : { } : {
user: value => serialize_user_short(value), user: value => serialize_user_short(value),
access_token: value => serialize_token(value), access_token: value => serialize_token(value),
refresh_token: value => serialize_token(value), refresh_token: value => serialize_token(value),
job: value => serialize_job(value),
req: value => serialize_req(value), req: value => serialize_req(value),
res: value => serialize_res(value), res: value => serialize_res(value),
}, },
@@ -52,7 +71,13 @@ import { serialize_token, serialize_user_short, serialize_user_long, serialize_r
} }
} }
} }
}) }),
BooksModule,
ProvidersModule,
SeriesModule,
LibraryModule,
ConfigModule,
AssetModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService, UsersService], providers: [AppService, UsersService],

View File

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

View File

@@ -0,0 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
const file_path = path.join(process.cwd(), './assets/config/config.json');
const file_content = fs.readFileSync(file_path).toString();
export const AppConfig = JSON.parse(file_content);

View File

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

View File

@@ -0,0 +1,15 @@
import { Controller, Request, Get } from '@nestjs/common';
import { AppConfig } from './app-config';
@Controller('asset')
export class ConfigController {
@Get('config')
async config(
@Request() req,
) {
return {
success: true,
config: AppConfig
};
}
}

View File

@@ -1,9 +1,10 @@
import * as moment from 'moment'; import * as moment from 'moment';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { UserEntity } from 'src/users/users.entity'; import { UserEntity } from 'src/users/entities/users.entity';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AccessTokenDto } from './dto/access-token.dto';
@Injectable() @Injectable()
export class AuthAccessService { export class AuthAccessService {
@@ -13,7 +14,7 @@ export class AuthAccessService {
private logger: PinoLogger, private logger: PinoLogger,
) { } ) { }
async generate(user: UserEntity) { async generate(user: UserEntity): Promise<AccessTokenDto> {
const now = new Date(); const now = new Date();
const limit = parseInt(this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_EXPIRATION_MS')); const limit = parseInt(this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_EXPIRATION_MS'));
const expiration = moment(now).add(limit, 'ms').toDate(); const expiration = moment(now).add(limit, 'ms').toDate();
@@ -22,9 +23,9 @@ export class AuthAccessService {
{ {
username: user.userLogin, username: user.userLogin,
sub: user.userId, sub: user.userId,
iat: now.getTime(), iat: Math.floor(now.getTime() / 1000),
nbf: now.getTime(), nbf: Math.floor(now.getTime() / 1000) - 5 * 60,
exp: expiration.getTime(), exp: Math.floor(expiration.getTime() / 1000),
}, },
{ {
secret: this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET'), secret: this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET'),
@@ -45,4 +46,12 @@ export class AuthAccessService {
exp: expiration.getTime(), 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,15 +1,17 @@
import { Controller, Request, Post, UseGuards, Body, Res } from '@nestjs/common'; import { Controller, Request, Post, UseGuards, Body, Res, Delete, Patch, UnauthorizedException } from '@nestjs/common';
import { LoginAuthGuard } from './guards/login-auth.guard';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { UsersService } from 'src/users/users.service'; import { UsersService } from 'src/users/users.service';
import { RegisterUserDto } from './dto/register-user.dto'; import { RegisterUserDto } from './dto/register-user.dto';
import { Response } from 'express'; import { Response } from 'express';
import { JwtRefreshGuard } from './guards/jwt-refresh.guard'; import { JwtRefreshGuard } from './guards/jwt-refresh.guard';
import { OfflineGuard } from './guards/offline.guard'; import { OfflineGuard } from './guards/offline.guard';
import { UserEntity } from 'src/users/users.entity'; import { UserEntity } from 'src/users/entities/users.entity';
import { QueryFailedError } from 'typeorm'; import { QueryFailedError } from 'typeorm';
import { PinoLogger } from 'nestjs-pino'; 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') @Controller('auth')
export class AuthController { export class AuthController {
@@ -19,16 +21,18 @@ export class AuthController {
private logger: PinoLogger, private logger: PinoLogger,
) { } ) { }
@UseGuards(LoginAuthGuard) @UseGuards(OfflineGuard)
@Post('login') @Post('login')
async login( async login(
@Request() req, @Request() req,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
@Body() body: LoginDto,
) { ) {
let data: AuthenticationDto | null; let data: AuthenticationDto | null;
try { try {
data = await this.auth.login(req.user); data = await this.auth.login(body);
if (!data.access_token || !data.refresh_token || !data.refresh_exp) { if (!data.access_token || body.remember_me && (!data.refresh_token || !data.refresh_exp)) {
response.statusCode = 500;
return { return {
success: false, success: false,
error_message: 'Something went wrong with tokens while logging in.', error_message: 'Something went wrong with tokens while logging in.',
@@ -42,6 +46,16 @@ export class AuthController {
msg: 'Failed to login.', msg: 'Failed to login.',
error: err, error: err,
}); });
if (err instanceof UnauthorizedException) {
response.statusCode = 401;
return {
success: false,
error_message: 'Invalid credentials.',
};
}
response.statusCode = 500;
return { return {
success: false, success: false,
error_message: 'Something went wrong while logging in.', error_message: 'Something went wrong while logging in.',
@@ -52,13 +66,17 @@ export class AuthController {
httpOnly: true, httpOnly: true,
secure: true, secure: true,
expires: new Date(data.exp), expires: new Date(data.exp),
sameSite: 'strict',
}); });
response.cookie('Refresh', data.refresh_token, { if (body.remember_me) {
httpOnly: true, response.cookie('Refresh', data.refresh_token, {
secure: true, httpOnly: true,
expires: new Date(data.refresh_exp), secure: true,
}); expires: new Date(data.refresh_exp),
sameSite: 'strict',
});
}
this.logger.info({ this.logger.info({
class: AuthController.name, class: AuthController.name,
@@ -66,6 +84,7 @@ export class AuthController {
user: req.user, user: req.user,
access_token: data.access_token, access_token: data.access_token,
refresh_token: data.refresh_token, refresh_token: data.refresh_token,
remember_me: body.remember_me,
msg: 'User logged in.', msg: 'User logged in.',
}); });
@@ -74,18 +93,33 @@ export class AuthController {
}; };
} }
@UseGuards(JwtAccessGuard) @UseGuards(JwtMixedGuard)
@Post('logout') @Delete('login')
async logout( async logout(
@Request() req, @Request() req,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
console.log('logout cookie', req.cookies?.Refresh); const accessToken = req.cookies?.Authentication;
// TODO: delete refresh token from database. const refreshToken = req.cookies?.Refresh;
// await this.auth.delete(req.cookies?.Refresh);
response.clearCookie('Refresh');
response.clearCookie('Authentication'); response.clearCookie('Authentication');
response.clearCookie('Refresh');
if (!accessToken && !refreshToken && !await this.auth.revoke(req.user.userId, refreshToken)) {
// User has already logged off.
this.logger.info({
class: AuthController.name,
method: this.logout.name,
user: req.user,
msg: 'User has already logged off based on ' + (!refreshToken ? 'cookies' : 'database'),
});
response.statusCode = 400;
return {
success: false,
error_message: 'User has already logged off.',
};
}
this.logger.info({ this.logger.info({
class: AuthController.name, class: AuthController.name,
@@ -94,61 +128,60 @@ export class AuthController {
msg: 'User logged off', msg: 'User logged off',
}); });
return req.logout(); return {
success: true,
};
} }
@UseGuards(JwtRefreshGuard) @UseGuards(JwtRefreshGuard)
@Post('refresh') @Patch('login')
async refresh( async refresh(
@Request() req, @Request() req,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
try { this.logger.info({
const refresh_token = req.cookies.Refresh; class: AuthController.name,
const data = await this.auth.renew(req.user, refresh_token); method: this.refresh.name,
user: req.user,
refresh_token: req.cookies.Refresh,
msg: 'Attempting to renew access token.',
});
response.cookie('Authentication', data.access_token, { const results = await this.auth.verify(req.cookies.Authentication, req.cookies.Refresh);
httpOnly: true,
secure: true, if (results.validation === false) {
expires: new Date(data.exp), this.logger.info({
});
this.logger.debug({
class: AuthController.name, class: AuthController.name,
method: this.refresh.name, method: this.refresh.name,
user: req.user, user: req.user,
access_token: data.access_token, refresh_token: req.cookies.Refresh,
msg: 'Updated Authentication cookie for access token.', msg: 'Refresh token is invalid. Access token is not refreshing.',
}); });
if (data.refresh_token != refresh_token) { response.statusCode = 400;
response.cookie('Refresh', data.refresh_token, {
httpOnly: true,
secure: true,
expires: new Date(data.refresh_exp),
});
this.logger.debug({
class: AuthController.name,
method: this.refresh.name,
user: req.user,
refresh_token: data.refresh_token,
msg: 'Updated Refresh cookie for refresh token.',
});
}
return { success: true };
} catch (err) {
this.logger.error({
class: AuthController.name,
method: this.refresh.name,
user: req.user,
msg: 'Failed to refresh tokens.',
error: err,
});
return { return {
success: false, success: false,
error_message: 'Something went wrong.', error_message: 'Refresh token is invalid.',
}; };
} }
const data = await this.auth.renew(req.user);
response.cookie('Authentication', data.access_token, {
httpOnly: true,
secure: true,
expires: new Date(data.exp),
sameSite: 'strict',
});
this.logger.debug({
class: AuthController.name,
method: this.refresh.name,
user: req.user,
access_token: data.access_token,
msg: 'Updated Authentication cookie for access token.',
});
return { success: true };
} }
@UseGuards(OfflineGuard) @UseGuards(OfflineGuard)
@@ -158,6 +191,14 @@ export class AuthController {
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
@Body() body: RegisterUserDto, @Body() body: RegisterUserDto,
) { ) {
if (!AppConfig.features.registration) {
response.statusCode = 404;
return {
success: false,
error_message: 'Registration disabled.',
};
}
let user: UserEntity | null; let user: UserEntity | null;
let data: AuthenticationDto | null; let data: AuthenticationDto | null;
try { try {
@@ -178,6 +219,8 @@ export class AuthController {
user: req.user, user: req.user,
msg: 'Failed to register due to duplicate userLogin.', msg: 'Failed to register due to duplicate userLogin.',
}); });
response.statusCode = 409;
return { return {
success: false, success: false,
error_message: 'Username already exist.', error_message: 'Username already exist.',
@@ -191,6 +234,8 @@ export class AuthController {
msg: 'Failed to register.', msg: 'Failed to register.',
error: err, error: err,
}); });
response.statusCode = 500;
return { return {
success: false, success: false,
error_message: 'Something went wrong when creating user.', error_message: 'Something went wrong when creating user.',
@@ -198,8 +243,12 @@ export class AuthController {
} }
try { try {
data = await this.auth.login(user); data = await this.auth.login({
if (!data.access_token || !data.refresh_token || !data.refresh_exp) { user_login: body.user_login,
password: body.password,
remember_me: false,
});
if (!data.access_token) {
this.logger.error({ this.logger.error({
class: AuthController.name, class: AuthController.name,
method: this.register.name, method: this.register.name,
@@ -208,6 +257,8 @@ export class AuthController {
refresh_token: data.refresh_token, refresh_token: data.refresh_token,
msg: 'Failed to generate tokens after registering.', msg: 'Failed to generate tokens after registering.',
}); });
response.statusCode = 500;
return { return {
success: false, success: false,
error_message: 'Something went wrong with tokens while logging in.', error_message: 'Something went wrong with tokens while logging in.',
@@ -221,6 +272,17 @@ export class AuthController {
msg: 'Failed to login after registering.', msg: 'Failed to login after registering.',
error: err, error: err,
}); });
// This should never happen...
if (err instanceof UnauthorizedException) {
response.statusCode = 401;
return {
success: false,
error_message: 'Invalid credentials.',
};
}
response.statusCode = 500;
return { return {
success: false, success: false,
error_message: 'Something went wrong while logging in.', error_message: 'Something went wrong while logging in.',
@@ -231,16 +293,34 @@ export class AuthController {
httpOnly: true, httpOnly: true,
secure: true, secure: true,
expires: new Date(data.exp), expires: new Date(data.exp),
}); sameSite: 'strict',
response.cookie('Refresh', data.refresh_token, {
httpOnly: true,
secure: true,
expires: new Date(data.refresh_exp),
}); });
return { return {
success: true, 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,23 +2,23 @@ import { Module } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { UsersModule } from 'src/users/users.module'; import { UsersModule } from 'src/users/users.module';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { LoginStrategy } from './strategies/login.strategy';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { JwtOptions } from './jwt.options'; 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 { AuthRefreshService } from './auth.refresh.service';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthRefreshTokenEntity } from './entities/auth.refresh-token.entity'; import { AuthRefreshTokenEntity } from './entities/auth.refresh-token.entity';
import { AuthAccessService } from './auth.access.service'; import { AuthAccessService } from './auth.access.service';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([AuthRefreshTokenEntity]), TypeOrmModule.forFeature([AuthRefreshTokenEntity]),
ConfigModule, ConfigModule,
UsersModule, UsersModule,
PassportModule, PassportModule.register({ session: false }),
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
extraProviders: [ConfigService], extraProviders: [ConfigService],
@@ -34,8 +34,8 @@ import { AuthAccessService } from './auth.access.service';
AuthAccessService, AuthAccessService,
AuthRefreshService, AuthRefreshService,
AuthService, AuthService,
JwtStrategy, JwtAccessStrategy,
LoginStrategy, JwtRefreshStrategy,
], ],
controllers: [AuthController] controllers: [AuthController]
}) })

View File

@@ -1,11 +1,11 @@
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as moment from "moment"; import * as moment from "moment";
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { UUID } from 'crypto'; import { UUID } from 'crypto';
import { UserEntity } from 'src/users/users.entity'; import { UserEntity } from 'src/users/entities/users.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AuthRefreshTokenEntity } from './entities/auth.refresh-token.entity'; import { AuthRefreshTokenEntity } from './entities/auth.refresh-token.entity';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
@@ -20,98 +20,46 @@ export class AuthRefreshService {
private logger: PinoLogger, private logger: PinoLogger,
) { } ) { }
async generate(user: UserEntity) {
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.
const now = new Date(); const now = new Date();
const threshhold = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_THRESHHOLD_MS')); const expirationTime = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_MS'));
if (!refreshToken || now.getTime() - expiration.getTime() > threshhold) { const expiration = moment(now).add(expirationTime, 'ms').toDate();
if (refreshToken) { const refreshToken = await this.jwts.signAsync(
this.authRefreshTokenRepository.delete({ {
tokenHash: refreshToken, username: user.userLogin,
userId: user.userId, sub: user.userId,
}); iat: Math.floor(now.getTime() / 1000),
nbf: Math.floor(now.getTime() / 1000) - 5 * 60,
this.logger.debug({ exp: Math.floor(expiration.getTime() / 1000),
class: AuthRefreshService.name, },
method: this.generate.name, {
user, secret: this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_SECRET'),
refresh_token: refreshToken,
exp: expiration,
msg: 'Deleted previous refresh token.',
});
} }
);
const limit = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_MS')); this.logger.debug({
expiration = moment(now).add(limit, 'ms').toDate(); class: AuthRefreshService.name,
refreshToken = await this.jwts.signAsync( method: this.generate.name,
{ user,
username: user.userLogin, refresh_token: refreshToken,
sub: user.userId, exp: expiration,
iat: now.getTime(), msg: 'Generated a new refresh token.',
nbf: now.getTime(), });
exp: expiration.getTime(),
},
{
secret: this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_SECRET'),
}
);
this.logger.debug({ this.authRefreshTokenRepository.insert({
class: AuthRefreshService.name, tokenHash: this.hash(refreshToken),
method: this.generate.name, userId: user.userId,
user, exp: expiration
refresh_token: refreshToken, });
exp: expiration,
msg: 'Generated a new refresh token.',
});
this.authRefreshTokenRepository.insert({ this.logger.debug({
tokenHash: refreshToken, class: AuthRefreshService.name,
userId: user.userId, method: this.generate.name,
exp: expiration user,
}); refresh_token: refreshToken,
exp: expiration,
this.logger.debug({ msg: 'Inserted the new refresh token into the database.',
class: AuthRefreshService.name, });
method: this.generate.name,
user,
refresh_token: refreshToken,
exp: expiration,
msg: 'Inserted the new refresh token.',
});
}
return { return {
refresh_token: refreshToken, refresh_token: refreshToken,
@@ -127,15 +75,28 @@ export class AuthRefreshService {
return null; return null;
} }
const buffer = Buffer.from(refreshToken, 'utf8');
const hash = crypto.createHash('sha256').update(buffer).digest('base64');
return await this.authRefreshTokenRepository.findOneBy({ return await this.authRefreshTokenRepository.findOneBy({
tokenHash: hash, tokenHash: this.hash(refreshToken),
userId: userId, userId: userId,
}); });
} }
private hash(refreshToken: string): string {
const buffer = Buffer.from(refreshToken, 'utf8');
return crypto.createHash('sha256').update(buffer).digest('base64');
}
async revoke(userId: UUID, refreshToken: string) {
if (!userId || !refreshToken) {
return null;
}
return await this.authRefreshTokenRepository.delete({
userId,
tokenHash: this.hash(refreshToken),
});
}
async validate( async validate(
refreshToken: string, refreshToken: string,
userId: UUID, userId: UUID,
@@ -143,4 +104,14 @@ export class AuthRefreshService {
const refresh = await this.get(refreshToken, userId); const refresh = await this.get(refreshToken, userId);
return refresh && refresh.exp.getTime() > new Date().getTime(); return refresh && refresh.exp.getTime() > new Date().getTime();
} }
async verify(
refreshToken: string
): Promise<any> {
return await this.jwts.verifyAsync(refreshToken,
{
secret: this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_SECRET'),
}
);
}
} }

View File

@@ -1,8 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserEntity } from 'src/users/users.entity'; import { UserEntity } from 'src/users/entities/users.entity';
import { UsersService } from 'src/users/users.service'; import { UsersService } from 'src/users/users.service';
import { AuthRefreshService } from './auth.refresh.service'; import { AuthRefreshService } from './auth.refresh.service';
import { AuthAccessService } from './auth.access.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() @Injectable()
export class AuthService { export class AuthService {
@@ -13,30 +18,127 @@ export class AuthService {
) { } ) { }
async login(user: UserEntity): Promise<AuthenticationDto> { async login(
return this.renew(user, null); 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( async validate(
username: string, username: string,
password: string, password: string,
): Promise<UserEntity | null> { ): Promise<UserEntity | null> {
return await this.users.findOne({ username, password }); return await this.users.findOne({ user_login: username, password, remember_me: false });
} }
async renew( async verify(
user: UserEntity, accessToken: string,
refresh_token: string refreshToken: string
): Promise<AuthenticationDto> { ): Promise<{ validation: boolean, userId: UUID | null, username: string | null }> {
const new_refresh_data = await this.refreshTokens.generate(user, refresh_token); let access: any = null;
const new_refresh_token = new_refresh_data.refresh_token; let refresh: any = null;
const new_refresh_exp = new_refresh_data.exp;
const access_token = await this.accessTokens.generate(user); 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 { return {
...access_token, validation: true,
refresh_token: new_refresh_token, userId: (access ?? refresh).sub,
refresh_exp: new_refresh_exp, username: (access ?? refresh).username,
} };
}
async revoke(
userId: UUID,
refreshToken: string
): Promise<boolean> {
const res = await this.refreshTokens.revoke(userId, refreshToken);
return res?.affected === 1;
} }
} }

View File

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

View File

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

@@ -1,20 +1,16 @@
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { IsNotEmpty } from 'class-validator';
import { UUID } from 'crypto'; import { UUID } from 'crypto';
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm'; import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
@Entity("refresh_tokens") @Entity("refresh_tokens")
export class AuthRefreshTokenEntity { export class AuthRefreshTokenEntity {
@PrimaryColumn({ name: 'user_id' }) @PrimaryColumn({ name: 'user_id' })
@IsNotEmpty()
readonly userId: UUID; readonly userId: UUID;
@PrimaryColumn({ name: 'refresh_token_hash' }) @PrimaryColumn({ name: 'refresh_token_hash' })
@IsNotEmpty()
tokenHash: string; tokenHash: string;
@Column() @Column({ name: 'exp' })
@IsNotEmpty()
exp: Date; exp: Date;
@BeforeInsert() @BeforeInsert()

View File

@@ -1,17 +1,10 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@Injectable() @Injectable()
export class JwtAccessAdminGuard extends AuthGuard('jwt') { export class JwtAccessAdminGuard extends AuthGuard('jwt-access') {
canActivate(context: ExecutionContext) {
// Add your custom authentication logic here
// for example, call super.logIn(request) to establish a session.
return super.canActivate(context);
}
handleRequest(err, user, info) { handleRequest(err, user, info) {
// You can throw an exception based on either "info" or "err" arguments
if (err || !user || !user.isAdmin) { if (err || !user || !user.isAdmin) {
throw err || new UnauthorizedException(); throw err || new UnauthorizedException();
} }

View File

@@ -1,6 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@Injectable() @Injectable()
export class JwtAccessGuard extends AuthGuard('jwt') { } export class JwtAccessGuard extends AuthGuard('jwt-access') {
handleRequest(err, user, info) {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}

View File

@@ -0,0 +1,12 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtMixedGuard extends AuthGuard(['jwt-access', 'jwt-refresh']) {
handleRequest(err, user, info) {
if (err || !user) {
throw err || new ForbiddenException();
}
return user;
}
}

View File

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

View File

@@ -1,13 +1,13 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs'; import { AuthGuard } from '@nestjs/passport';
@Injectable() @Injectable()
export class OfflineGuard implements CanActivate { export class OfflineGuard extends AuthGuard(['jwt-access', 'jwt-refresh']) {
canActivate( handleRequest(err, user, info) {
context: ExecutionContext, if (err || user) {
): boolean | Promise<boolean> | Observable<boolean> { throw err || new ForbiddenException();
const request = context.switchToHttp().getRequest(); }
return !request.user; return user;
} }
} }

View File

@@ -0,0 +1,50 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UsersService } from 'src/users/users.service';
@Injectable()
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') {
constructor(private users: UsersService, private config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
//ExtractJwt.fromAuthHeaderAsBearerToken(),
JwtAccessStrategy.extract,
]),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET'),
issuer: config.getOrThrow<string>('AUTH_JWT_ISSUER'),
audience: config.getOrThrow<string>('AUTH_JWT_AUDIENCE'),
});
}
private static extract(req: any): string | null {
const jwt = req.cookies?.Authentication;
if (!jwt)
return null;
return jwt;
}
async validate(payload: any) {
if (!payload) {
throw new UnauthorizedException();
}
if (payload.iss != this.config.getOrThrow('AUTH_JWT_ISSUER')) {
throw new UnauthorizedException();
}
if (payload.aud != this.config.getOrThrow('AUTH_JWT_AUDIENCE')) {
throw new UnauthorizedException();
}
const user = await this.users.findById(payload.sub);
if (!user || user.userLogin != payload.username) {
throw new UnauthorizedException();
}
return user;
}
}

View File

@@ -1,25 +1,54 @@
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { AuthRefreshService } from '../auth.refresh.service'; import { AuthRefreshService } from '../auth.refresh.service';
import { Request } from 'express'; import { Request } from 'express';
import { UsersService } from 'src/users/users.service';
@Injectable() @Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor(private auth: AuthRefreshService, private config: ConfigService) { constructor(private auth: AuthRefreshService, private users: UsersService, private config: ConfigService) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromExtractors([
//ExtractJwt.fromAuthHeaderAsBearerToken(),
JwtRefreshStrategy.extract,
]),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: config.get<string>('AUTH_JWT_REFRESH_SECRET'), secretOrKey: config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_SECRET'),
issuer: config.getOrThrow<string>('AUTH_JWT_ISSUER'), issuer: config.getOrThrow<string>('AUTH_JWT_ISSUER'),
audience: config.getOrThrow<string>('AUTH_JWT_AUDIENCE'), audience: config.getOrThrow<string>('AUTH_JWT_AUDIENCE'),
passReqToCallback: true, passReqToCallback: true,
}); });
} }
private static extract(req: any): string | null {
const jwt = req.cookies?.Refresh;
if (!jwt)
return null;
return jwt;
}
async validate(request: Request, payload: any) { async validate(request: Request, payload: any) {
return this.auth.validate(request.cookies?.Refresh, payload.sub); const user = await this.users.findById(payload.sub);
if (!user || user.userLogin != payload.username) {
throw new UnauthorizedException();
}
if (payload.iss != this.config.getOrThrow('AUTH_JWT_ISSUER')) {
throw new UnauthorizedException();
}
if (payload.aud != this.config.getOrThrow('AUTH_JWT_AUDIENCE')) {
throw new UnauthorizedException();
}
const refreshToken = request.cookies?.Refresh;
if (!refreshToken || !this.auth.validate(refreshToken, payload.sub)) {
throw new UnauthorizedException();
}
return user;
} }
} }

View File

@@ -1,28 +0,0 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UsersService } from 'src/users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private users: UsersService, private config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET'),
issuer: config.getOrThrow<string>('AUTH_JWT_ISSUER'),
audience: config.getOrThrow<string>('AUTH_JWT_AUDIENCE'),
passReqToCallback: true,
});
}
async validate(req: Request, payload: any) {
const user = await this.users.findById(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}

View File

@@ -1,20 +0,0 @@
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
@Injectable()
export class LoginStrategy extends PassportStrategy(Strategy, 'login') {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, password: string): Promise<any> {
const user = await this.authService.validate(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { BooksService } from './books.service';
import { BookEntity } from './entities/book.entity';
import { BookOriginEntity } from './entities/book-origin.entity';
import { BookStatusEntity } from './entities/book-status.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HttpModule } from '@nestjs/axios';
import { SeriesModule } from 'src/series/series.module';
@Module({
imports: [
TypeOrmModule.forFeature([
BookEntity,
BookOriginEntity,
BookStatusEntity,
]),
SeriesModule,
HttpModule,
],
controllers: [],
exports: [
BooksService
],
providers: [BooksService]
})
export class BooksModule { }

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BooksService } from './books.service';
describe('BooksService', () => {
let service: BooksService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [BooksService],
}).compile();
service = module.get<BooksService>(BooksService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,123 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BookEntity } from './entities/book.entity';
import { DeleteResult, In, InsertResult, Repository } from 'typeorm';
import { BookOriginEntity } from './entities/book-origin.entity';
import { BookStatusEntity } from './entities/book-status.entity';
import { UUID } from 'crypto';
import { CreateBookDto } from './dto/create-book.dto';
import { CreateBookOriginDto } from './dto/create-book-origin.dto';
import { CreateBookStatusDto } from './dto/create-book-status.dto';
import { DeleteBookStatusDto } from './dto/delete-book-status.dto';
import { SeriesDto } from 'src/series/dto/series.dto';
import { SeriesSubscriptionDto } from 'src/series/dto/series-subscription.dto';
import { BookOriginDto } from './dto/book-origin.dto';
@Injectable()
export class BooksService {
constructor(
@InjectRepository(BookEntity)
private bookRepository: Repository<BookEntity>,
@InjectRepository(BookOriginEntity)
private bookOriginRepository: Repository<BookOriginEntity>,
@InjectRepository(BookStatusEntity)
private bookStatusRepository: Repository<BookStatusEntity>,
) { }
async createBook(book: CreateBookDto): Promise<InsertResult> {
const entity = this.bookRepository.create(book);
return await this.bookRepository.createQueryBuilder()
.insert()
.into(BookEntity)
.values(entity)
.returning('book_id')
.execute();
}
async addBookOrigin(origin: CreateBookOriginDto): Promise<InsertResult> {
return await this.bookOriginRepository.insert(origin);
}
async deleteBookOrigin(origin: BookOriginDto[]): Promise<DeleteResult> {
return await this.bookOriginRepository.createQueryBuilder()
.delete()
.where({
whereFactory: {
bookOriginId: In(origin.map(o => o.bookOriginId)),
},
})
.execute();
}
async deleteBookStatus(status: DeleteBookStatusDto): Promise<DeleteResult> {
return await this.bookStatusRepository.createQueryBuilder()
.delete()
.where({
whereFactory: status,
})
.execute();
}
async findBooksByIds(bookIds: UUID[]): Promise<BookEntity[]> {
return await this.bookRepository.find({
where: {
bookId: In(bookIds)
}
});
}
async findBooksFromSeries(series: SeriesDto): Promise<BookEntity[]> {
return await this.bookRepository.find({
where: {
providerSeriesId: series.providerSeriesId,
provider: series.provider,
}
});
}
async findBooks(): Promise<BookEntity[]> {
return await this.bookRepository.find();
}
async findActualBookStatusesTrackedBy(userId: UUID, series: SeriesDto): Promise<BookStatusEntity[]> {
return await this.bookStatusRepository.createQueryBuilder('s')
.innerJoin('s.book', 'b')
.where('s.user_id = :id', { id: userId })
.andWhere('b.provider = :provider', { provider: series.provider })
.andWhere('b.providerSeriesId = :id', { id: series.providerSeriesId })
.addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider', 'b.providerSeriesId'])
.getMany();
}
async findBookStatusesTrackedBy(subscription: SeriesSubscriptionDto): Promise<any> {
return await this.bookRepository.createQueryBuilder('b')
.where('b.provider = :provider', { provider: subscription.provider })
.andWhere(`b.provider_series_id = :id`, { id: subscription.providerSeriesId })
.leftJoin('b.statuses', 's')
.where(`s.user_id = :id`, { id: subscription.userId })
.addSelect(['s.state'])
.getMany();
}
async updateBook(bookId: UUID, update: CreateBookDto) {
return await this.bookRepository.update({
bookId,
}, update);
}
async updateBookOrigin(bookOriginId: UUID, update: CreateBookOriginDto) {
return await this.bookOriginRepository.update({
bookOriginId
}, update);
}
async updateBookStatus(status: CreateBookStatusDto) {
status.modifiedAt = new Date();
await this.bookStatusRepository.createQueryBuilder()
.insert()
.values(status)
.orUpdate(['state', 'modified_at'], ['user_id', 'book_id'])
.execute();
}
}

View File

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

View File

@@ -0,0 +1,19 @@
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
import { UUID } from 'crypto';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
export class CreateBookOriginDto {
@IsUUID()
@IsNotEmpty()
readonly bookId: UUID;
@IsNumber()
@IsNotEmpty()
@Transform(({ value }) => value as BookOriginType)
type: BookOriginType;
@IsString()
@IsNotEmpty()
value: string;
}

View File

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

View File

@@ -0,0 +1,35 @@
import { Transform } from 'class-transformer';
import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, MaxLength } from 'class-validator';
export class CreateBookDto {
@IsString()
@IsOptional()
providerSeriesId: string;
@IsString()
@IsNotEmpty()
providerBookId: string;
@IsString()
@IsNotEmpty()
@MaxLength(128)
title: string;
@IsString()
@MaxLength(512)
desc: string;
@IsNumber()
@IsOptional()
@IsPositive()
volume: number | null;
@IsString()
@IsNotEmpty()
provider: string;
@IsDate()
@IsNotEmpty()
@Transform(({ value }) => new Date(value))
publishedAt: Date
}

View File

@@ -0,0 +1,13 @@
import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { UUID } from 'crypto';
export class DeleteBookStatusDto {
@IsUUID()
@IsNotEmpty()
readonly bookId: UUID;
@IsUUID()
@IsNotEmpty()
@IsOptional()
readonly userId: UUID;
}

View File

@@ -0,0 +1,23 @@
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
import { UUID } from 'crypto';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
export class UpdateBookOriginDto {
@IsUUID()
@IsNotEmpty()
readonly bookOriginId: UUID;
@IsUUID()
@IsNotEmpty()
readonly bookId: UUID;
@IsNumber()
@IsNotEmpty()
@Transform(({ value }) => value as BookOriginType)
type: BookOriginType;
@IsString()
@IsNotEmpty()
value: string;
}

View File

@@ -0,0 +1,40 @@
import { Transform } from 'class-transformer';
import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, IsUUID, MaxLength } from 'class-validator';
import { UUID } from 'crypto';
export class UpdateBookDto {
@IsUUID()
@IsNotEmpty()
readonly bookId: UUID;
@IsString()
@IsOptional()
providerSeriesId: string;
@IsString()
@IsNotEmpty()
providerBookId: string;
@IsString()
@IsNotEmpty()
@MaxLength(128)
title: string;
@IsString()
@MaxLength(512)
desc: string;
@IsNumber()
@IsOptional()
@IsPositive()
volume: number | null;
@IsString()
@IsNotEmpty()
provider: string;
@IsDate()
@IsNotEmpty()
@Transform(({ value }) => new Date(value))
publishedAt: Date
}

View File

@@ -0,0 +1,27 @@
import { UUID } from 'crypto';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { BookEntity } from './book.entity';
@Entity("book_origins")
@Unique(['bookOriginId', 'type', 'value'])
export class BookOriginEntity {
@PrimaryColumn({ name: 'book_origin_id' })
readonly bookOriginId: UUID;
@Column({ name: 'book_id' })
readonly bookId: UUID;
@Column({ name: 'origin_type' })
type: BookOriginType;
@Column({ name: 'origin_value' })
value: string;
@OneToOne(type => BookEntity, book => book.metadata)
@JoinColumn({
name: 'book_id',
referencedColumnName: 'bookId',
})
book: BookEntity;
}

View File

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

View File

@@ -0,0 +1,53 @@
import { UUID } from 'crypto';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { BookOriginEntity } from './book-origin.entity';
import { BookStatusEntity } from './book-status.entity';
import { SeriesEntity } from 'src/series/entities/series.entity';
@Entity("books")
@Unique(['providerSeriesId', 'providerBookId'])
export class BookEntity {
@PrimaryColumn({ name: 'book_id', type: 'uuid' })
readonly bookId: UUID;
@Column({ name: 'provider_series_id', type: 'text', nullable: true })
providerSeriesId: string;
@Column({ name: 'provider_book_id', type: 'text', nullable: false })
providerBookId: string;
@Column({ name: 'book_title', type: 'text', nullable: false })
title: string;
@Column({ name: 'book_desc', type: 'text', nullable: true })
desc: string;
@Column({ name: 'book_volume', type: 'integer', nullable: true })
volume: number;
@Column({ name: 'provider', type: 'varchar', nullable: false })
provider: string;
@Column({ name: 'published_at', type: 'timestamptz', nullable: false })
publishedAt: Date
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date;
@OneToMany(type => BookOriginEntity, origin => origin.book)
metadata: BookOriginEntity[];
@OneToMany(type => BookStatusEntity, status => status.book)
statuses: BookStatusEntity[];
@OneToOne(type => SeriesEntity, series => series.volumes)
@JoinColumn([{
name: 'provider_series_id',
referencedColumnName: 'providerSeriesId',
},
{
name: 'provider',
referencedColumnName: 'provider',
}])
series: SeriesEntity;
}

View File

@@ -0,0 +1,201 @@
import { OnQueueEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { GoogleSearchContext } from 'src/providers/contexts/google.search.context';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { ProvidersService } from 'src/providers/providers.service';
import { SeriesSubscriptionJobDto } from 'src/series/dto/series-subscription-job.dto';
import { LibraryService } from './library.service';
@Processor('library')
export class LibraryConsumer extends WorkerHost {
constructor(
private readonly library: LibraryService,
private readonly provider: ProvidersService,
private readonly logger: PinoLogger,
) {
super();
}
async process(job: Job, token?: string): Promise<any> {
this.logger.info({
class: LibraryConsumer.name,
method: this.process.name,
job: job,
msg: 'Started task on queue.',
});
if (job.name == 'new_series') {
const series: SeriesSubscriptionJobDto = job.data;
const books = await this.search(job, series, null);
let counter = 0;
for (let book of books) {
try {
// Force the provider's series id to be set, so that we know which series this belongs.
book.result.providerSeriesId = series.providerSeriesId;
await this.library.addBook(book.result);
} catch (err) {
this.logger.error({
class: LibraryConsumer.name,
method: this.process.name,
book: book.result,
score: book.score,
msg: 'Failed to add book in background during adding series.',
error: err,
});
} finally {
counter++;
job.updateProgress(25 + 75 * counter / books.length);
}
}
} else if (job.name == 'update_series') {
const series: SeriesSubscriptionJobDto = job.data;
const existingBooks = await this.library.findBooksFromSeries(series);
const existingVolumes = existingBooks.map(b => b.volume);
const lastPublishedBook = existingBooks.reduce((a, b) => a.publishedAt.getTime() > b.publishedAt.getTime() ? a : b);
const books = await this.search(job, series, lastPublishedBook?.publishedAt);
let counter = 0;
for (let book of books) {
if (existingVolumes.includes(book.result.volume)) {
continue;
}
try {
// Force the provider's series id to be set, so that we know which series this belongs.
book.result.providerSeriesId = series.providerSeriesId;
await this.library.addBook(book.result);
} catch (err) {
this.logger.error({
class: LibraryConsumer.name,
method: this.process.name,
book: book.result,
score: book.score,
msg: 'Failed to add book in background during series update.',
error: err,
});
} finally {
counter++;
job.updateProgress(25 + 75 * counter / books.length);
}
}
} else {
this.logger.warn({
class: LibraryConsumer.name,
method: this.process.name,
job: job,
msg: 'Unknown job name found.',
});
}
this.logger.info({
class: LibraryConsumer.name,
method: this.process.name,
job: job,
msg: 'Completed task on queue.',
});
return null;
}
private async search(job: Job, series: SeriesSubscriptionJobDto, after: Date | null): Promise<{ result: BookSearchResultDto, score: number }[]> {
let context = this.provider.generateSearchContext(series.provider, series.title) as GoogleSearchContext;
context.maxResults = '40';
if (after) {
context.orderBy = 'newest';
}
// Search for the book(s) via the provider.
// Up until end of results or after 3 unhelpful pages of results.
let results = [];
let related = [];
let pageSearchedCount = 0;
let unhelpfulResultsCount = 0;
do {
pageSearchedCount += 1;
results = await this.provider.search(context);
const potential = results.filter((r: BookSearchResultDto) => r.providerSeriesId == series.providerSeriesId || r.title == series.title && r.mediaType == series.mediaType);
if (potential.length > 0) {
related.push.apply(related, potential);
} else {
unhelpfulResultsCount += 1;
}
context = context.next();
job.updateProgress(pageSearchedCount * 5);
} while (results.length >= context.maxResults && (!after || after < results[results.length - 1].publishedAt));
// Sort & de-duplicate the entries received.
const books = related.map(book => this.toScore(book, series))
.sort((a, b) => a.result.volume - b.result.volume || b.score - a.score)
.filter((_, index, arr) => index == 0 || arr[index - 1].result.volume != arr[index].result.volume);
job.updateProgress(25);
this.logger.debug({
class: LibraryConsumer.name,
method: this.search.name,
job: job,
msg: 'Finished searching for book entries.',
results: {
pages: pageSearchedCount,
related_entries: related.length,
volumes: books.length,
}
});
return books;
}
@OnQueueEvent('failed')
onFailed(job: Job, err: Error) {
this.logger.error({
class: LibraryConsumer.name,
method: this.onFailed.name,
job: job,
msg: 'A library job failed.',
error: err,
});
}
@OnQueueEvent('paused')
onPaused() {
this.logger.info({
class: LibraryConsumer.name,
method: this.onPaused.name,
msg: 'Library jobs have been paused.',
});
}
@OnQueueEvent('resumed')
onResume(job: Job) {
this.logger.info({
class: LibraryConsumer.name,
method: this.onResume.name,
msg: 'Library jobs have resumed.',
});
}
@OnQueueEvent('waiting')
onWaiting(jobId: number | string) {
this.logger.info({
class: LibraryConsumer.name,
method: this.onWaiting.name,
msg: 'A library job is waiting...',
});
}
private toScore(book: BookSearchResultDto, series: SeriesSubscriptionJobDto): ({ result: BookSearchResultDto, score: number }) {
if (!book) {
return {
result: null,
score: -1,
}
}
return {
result: book,
score: (!!book.providerSeriesId ? 50 : 0) + (book.title == series.title ? 25 : 0) + (book.url.startsWith('https://play.google.com/store/books/details?') ? 10 : 0),
}
}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LibraryController } from './library.controller';
describe('LibraryController', () => {
let controller: LibraryController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [LibraryController],
}).compile();
controller = module.get<LibraryController>(LibraryController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,477 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Body, Controller, Delete, Get, Patch, Post, Put, Request, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { Queue } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { BooksService } from 'src/books/books.service';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { SeriesService } from 'src/series/series.service';
import { QueryFailedError } from 'typeorm';
import { UpdateBookDto } from 'src/books/dto/update-book.dto';
import { UpdateBookOriginDto } from 'src/books/dto/update-book-origin.dto';
import { LibraryService } from './library.service';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { SeriesDto } from 'src/series/dto/series.dto';
import { DeleteBookStatusDto } from 'src/books/dto/delete-book-status.dto';
import { CreateBookStatusDto } from 'src/books/dto/create-book-status.dto';
import { JwtAccessAdminGuard } from 'src/auth/guards/jwt-access.admin.guard';
import { BookOriginDto } from 'src/books/dto/book-origin.dto';
import { CreateSeriesDto } from 'src/series/dto/create-series.dto';
@UseGuards(JwtAccessGuard)
@Controller('library')
export class LibraryController {
constructor(
private readonly books: BooksService,
private readonly series: SeriesService,
private readonly library: LibraryService,
@InjectQueue('library') private readonly jobs: Queue,
private readonly logger: PinoLogger,
) { }
@Get('series')
async getSeries(
@Request() req,
) {
return {
success: true,
data: await this.series.getAllSeries(),
};
}
@Post('series')
async createSeries(
@Request() req,
@Body() body: CreateSeriesDto,
@Res({ passthrough: true }) response: Response,
) {
try {
await this.library.addSeries(body);
return {
success: true,
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') {
this.logger.warn({
class: LibraryController.name,
method: this.createSeries.name,
user: req.user,
body: body,
msg: 'Failed to create a series.',
error: err,
});
response.statusCode = 409;
return {
success: false,
error_message: 'Series already exists.',
};
}
}
this.logger.error({
class: LibraryController.name,
method: this.createSeries.name,
user: req.user,
body: body,
msg: 'Failed to create a series.',
error: err,
});
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
@Patch('series')
async updateSeries(
@Request() req,
@Body() body: CreateSeriesDto,
@Res({ passthrough: true }) response: Response,
) {
try {
await this.library.updateSeries(body);
return {
success: true,
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') {
this.logger.warn({
class: LibraryController.name,
method: this.updateSeries.name,
user: req.user,
msg: 'Failed to update a series.',
error: err,
});
// Subscription already exist.
response.statusCode = 409;
return {
success: false,
error_message: 'Series subscription already exists.',
};
}
}
this.logger.error({
class: LibraryController.name,
method: this.updateSeries.name,
user: req.user,
body: body,
msg: 'Failed to update a series.',
error: err,
});
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
@UseGuards(JwtAccessAdminGuard)
@Delete('series')
async deleteSeries(
@Request() req,
@Body() body: SeriesDto,
@Res({ passthrough: true }) response: Response,
) {
try {
const del = await this.series.deleteSeries(body);
return {
success: del && del.affected > 0,
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23503') {
this.logger.warn({
class: LibraryController.name,
method: this.deleteSeries.name,
user: req.user,
body: body,
msg: 'Failed to delete a series.',
error: err,
});
response.statusCode = 404;
return {
success: false,
error_message: 'The series does not exist.',
};
}
}
this.logger.error({
class: LibraryController.name,
method: this.deleteSeries.name,
user: req.user,
body: body,
msg: 'Failed to delete a series.',
error: err,
});
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
@Get('series/subscriptions')
async getSeriesSubscriptions(
@Request() req,
) {
return {
success: true,
data: await this.series.getSeriesSubscribedBy(req.user.userId),
};
}
@Post('series/subscribe')
async subscribe(
@Request() req,
@Body() body: SeriesDto,
@Res({ passthrough: true }) response: Response,
) {
try {
await this.library.addSubscription({
...body,
userId: req.user.userId,
});
return {
success: true,
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') {
this.logger.warn({
class: LibraryController.name,
method: this.subscribe.name,
user: req.user,
body: body,
msg: 'Failed to subscribe to a series.',
error: err,
});
// Subscription already exists.
response.statusCode = 409;
return {
success: false,
error_message: 'Series subscription already exists.',
};
} else if (err.driverError.code == '23503') {
this.logger.warn({
class: LibraryController.name,
method: this.subscribe.name,
user: req.user,
body: body,
msg: 'Failed to subscribe to a series.',
error: err,
});
// Series does not exist.
response.statusCode = 404;
return {
success: false,
error_message: 'Series does not exist.',
};
}
}
this.logger.error({
class: LibraryController.name,
method: this.subscribe.name,
user: req.user,
body: body,
msg: 'Failed to subscribe to a series.',
error: err,
});
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
@Delete('series/subscribe')
async deleteSeriesSubscription(
@Request() req,
@Body() body: SeriesDto,
) {
const del = await this.series.deleteSeriesSubscription({
...body,
userId: req.user.userId,
});
return {
success: del && del.affected > 0,
};
}
@Get('books')
async getBooksFromUser(
@Request() req,
) {
return {
success: true,
data: await this.library.findBooks(),
};
}
@Post('books')
async createBook(
@Request() req,
@Body() body: BookSearchResultDto,
@Res({ passthrough: true }) response: Response,
) {
if (body.provider && body.providerSeriesId) {
this.logger.warn({
class: LibraryController.name,
method: this.createBook.name,
user: req.user,
body: body,
msg: 'Failed to create book due to book being part of a series.',
});
response.statusCode = 400;
return {
success: false,
error_message: 'This book is part of a seris. Use the series route to create a series.',
}
}
try {
return {
success: true,
data: await this.books.createBook(body),
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') {
this.logger.warn({
class: LibraryController.name,
method: this.createBook.name,
user: req.user,
body: body,
msg: 'Failed to create book.',
error: err,
});
// Book exists already.
response.statusCode = 409;
return {
success: false,
error_message: 'The book has already been added previously.',
};
} else if (err.driverError.code == '23503') {
this.logger.warn({
class: LibraryController.name,
method: this.createBook.name,
user: req.user,
body: body,
msg: 'Failed to create book.',
error: err,
});
// Series is missing.
response.statusCode = 404;
return {
success: false,
error_message: 'Series does not exist.',
};
}
}
this.logger.error({
class: LibraryController.name,
method: this.createBook.name,
user: req.user,
body: body,
msg: 'Failed to create a book.',
error: err,
});
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong while adding the book.',
};
}
}
@Put('books')
async updateBook(
@Body() body: UpdateBookDto,
) {
const data = { ...body };
delete data['bookId'];
const result = await this.books.updateBook(body.bookId, data);
return {
success: result && result.affected > 0,
};
}
@Delete('books/origins')
async deleteBookOrigin(
@Body() body: BookOriginDto[],
) {
const result = await this.books.deleteBookOrigin(body);
return {
success: result && result.affected > 0,
};
}
@Put('books/origins')
async updateBookOrigin(
@Body() body: UpdateBookOriginDto,
) {
const data = { ...body };
delete data['bookOriginId'];
const result = await this.books.updateBookOrigin(body.bookOriginId, data);
return {
success: result && result.affected > 0,
};
}
@Get('books/status')
async getBookStatus(
@Request() req,
@Body() body: SeriesDto,
) {
return {
success: true,
data: await this.books.findActualBookStatusesTrackedBy(req.user.userId, body),
};
}
@Put('books/status')
async updateBookStatus(
@Request() req,
@Body() body: CreateBookStatusDto,
@Res({ passthrough: true }) response: Response,
) {
try {
await this.books.updateBookStatus(body);
return {
success: true,
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23503') {
this.logger.warn({
class: LibraryController.name,
method: this.updateBookStatus.name,
user: req.user,
body: body,
msg: 'Failed to update the user\'s status of a book.',
error: err,
});
response.statusCode = 404;
return {
success: false,
error_message: 'The book does not exist.',
};
}
}
this.logger.error({
class: LibraryController.name,
method: this.updateBookStatus.name,
user: req.user,
body: body,
msg: 'Failed to update the user\'s status of a book.',
error: err,
});
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
@Delete('books/status')
async deleteBookStatus(
@Body() body: DeleteBookStatusDto,
) {
const result = await this.books.deleteBookStatus(body);
return {
success: result && result.affected > 0,
};
}
}

View File

@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { LibraryService } from './library.service';
import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookOriginEntity } from 'src/books/entities/book-origin.entity';
import { BookStatusEntity } from 'src/books/entities/book-status.entity';
import { BookEntity } from 'src/books/entities/book.entity';
import { ProvidersModule } from 'src/providers/providers.module';
import { SeriesModule } from 'src/series/series.module';
import { SeriesEntity } from 'src/series/entities/series.entity';
import { SeriesSubscriptionEntity } from 'src/series/entities/series-subscription.entity';
import { BooksService } from 'src/books/books.service';
import { SeriesService } from 'src/series/series.service';
import { BullModule } from '@nestjs/bullmq';
import { LibraryConsumer } from './library.consumer';
import { LibraryController } from './library.controller';
@Module({
imports: [
TypeOrmModule.forFeature([
BookEntity,
BookOriginEntity,
BookStatusEntity,
SeriesEntity,
SeriesSubscriptionEntity,
]),
BullModule.registerQueue({
name: 'library',
}),
SeriesModule,
HttpModule,
ProvidersModule,
],
exports: [LibraryService],
providers: [LibraryService, BooksService, SeriesService, LibraryConsumer],
controllers: [LibraryController]
})
export class LibraryModule { }

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LibraryService } from './library.service';
describe('LibraryService', () => {
let service: LibraryService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [LibraryService],
}).compile();
service = module.get<LibraryService>(LibraryService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,150 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { BooksService } from 'src/books/books.service';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { CreateSeriesDto } from 'src/series/dto/create-series.dto';
import { SeriesSubscriptionDto } from 'src/series/dto/series-subscription.dto';
import { SeriesDto } from 'src/series/dto/series.dto';
import { SeriesService } from 'src/series/series.service';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
@Injectable()
export class LibraryService {
constructor(
private readonly books: BooksService,
private readonly series: SeriesService,
@InjectQueue('library') private readonly jobs: Queue,
private readonly logger: PinoLogger,
) { }
async addSeries(series: CreateSeriesDto) {
const result = await this.series.addSeries(series);
this.logger.debug({
class: LibraryService.name,
method: this.addSubscription.name,
series: series,
msg: 'Series saved to database.',
});
this.jobs.add('new_series', series);
return {
success: true,
};
}
async addSubscription(series: SeriesSubscriptionDto) {
return await this.series.addSeriesSubscription({
userId: series.userId,
providerSeriesId: series.providerSeriesId,
provider: series.provider,
});
}
async addBook(book: BookSearchResultDto) {
this.logger.debug({
class: LibraryService.name,
method: this.addBook.name,
book: book,
msg: 'Saving book to database...',
});
const bookData = await this.books.createBook({
title: book.title,
desc: book.desc,
providerSeriesId: book.providerSeriesId,
providerBookId: book.providerBookId,
volume: book.volume,
provider: book.provider,
publishedAt: book.publishedAt,
});
const bookId = bookData.identifiers[0]['bookId'];
const tasks = [];
if (book.authors && book.authors.length > 0) {
tasks.push(book.authors.map(author => this.books.addBookOrigin({
bookId,
type: BookOriginType.AUTHOR,
value: author,
})));
}
if (book.categories && book.categories.length > 0) {
tasks.push(book.categories.map(category => this.books.addBookOrigin({
bookId,
type: BookOriginType.CATEGORY,
value: category
})));
}
if (book.language) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.LANGUAGE,
value: book.language,
}));
}
if (book.maturityRating) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.MATURITY_RATING,
value: book.maturityRating,
}));
}
if (book.thumbnail) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.PROVIDER_THUMBNAIL,
value: book.thumbnail,
}));
}
if (book.url) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.PROVIDER_URL,
value: book.url,
}));
}
if ('ISBN_10' in book.industryIdentifiers) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.ISBN_10,
value: book.industryIdentifiers['ISBN_10'],
}));
}
if ('ISBN_13' in book.industryIdentifiers) {
tasks.push(this.books.addBookOrigin({
bookId,
type: BookOriginType.ISBN_10,
value: book.industryIdentifiers['ISBN_13'],
}));
}
await Promise.all(tasks);
this.logger.info({
class: LibraryService.name,
method: this.addBook.name,
book: book,
msg: 'Book saved to database.',
});
return bookId;
}
async findBooks() {
return await this.books.findBooks();
}
async findBooksFromSeries(series: SeriesDto) {
return await this.books.findBooksFromSeries(series);
}
async updateSeries(series: CreateSeriesDto) {
return await this.jobs.add('update_series', series);
}
}

View File

@@ -1,4 +1,4 @@
import { UserEntity } from "./users/users.entity"; import { UserEntity } from "./users/entities/users.entity";
export function serialize_user_short(value: UserEntity) { export function serialize_user_short(value: UserEntity) {
if (!value) { if (!value) {
@@ -21,6 +21,7 @@ export function serialize_user_long(value: UserEntity) {
} }
export function serialize_token(value: string) { export function serialize_token(value: string) {
if (!value) return null;
return '...' + value.substring(Math.max(value.length - 12, value.length / 2) | 0); return '...' + value.substring(Math.max(value.length - 12, value.length / 2) | 0);
} }
@@ -29,13 +30,29 @@ export function serialize_req(value) {
return value; return value;
} }
value = { ...value };
delete value['remoteAddress'] delete value['remoteAddress']
delete value['remotePort'] delete value['remotePort']
if (value.headers) { if (value.headers) {
const headers = value.headers; const headers = value.headers = { ...value.headers };
if (headers['Authorization']) { if (headers['authorization']) {
headers['Authorization'] = headers['Authorization'].substring(Math.max(0, headers['Authorization'].length - 12)) headers['authorization'] = '...' + headers['authorization'].substring(Math.max(0, headers['authorization'].length - 16))
}
if (headers['cookie']) {
const cookies = headers['cookie'].split(';')
.map((c: string) => {
c = c.trim();
const index = c.indexOf('=');
if (index < 0)
return c;
const cookieValue = c.substring(index + 1);
return c.substring(0, index) + '=...' + cookieValue.substring(Math.max(cookieValue.length - 16, cookieValue.length / 2));
});
headers['cookie'] = cookies.join('; ');
} }
} }
return value; return value;
@@ -46,7 +63,8 @@ export function serialize_res(value) {
return value; return value;
} }
const headers = value.headers; value = { ...value };
const headers = value.headers = { ...value.headers };
delete headers['x-powered-by']; delete headers['x-powered-by'];
if (headers['set-cookie']) { if (headers['set-cookie']) {
@@ -62,3 +80,15 @@ export function serialize_res(value) {
} }
return 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.useLogger(app.get(Logger));
app.useGlobalInterceptors(new LoggerErrorInterceptor()); app.useGlobalInterceptors(new LoggerErrorInterceptor());
await app.listen(process.env.PORT ?? 3001); await app.listen(process.env.WEB_API_PORT ?? 3001);
} }
bootstrap(); bootstrap();

View File

@@ -0,0 +1,144 @@
import { SearchContext } from "./search.context";
export class GoogleSearchContext extends SearchContext {
constructor(searchQuery: string, params: { [key: string]: string }) {
super('google', searchQuery, params);
}
generateQueryParams(): string {
const filterParams = ['maxResults', 'startIndex', 'orderBy', 'langRestrict'];
const searchParams = ['intitle', 'inauthor', 'inpublisher', 'subject', 'isbn'];
const queryParams = filterParams
.map(p => p in this.params ? p + '=' + this.params[p] : undefined)
.filter(p => p !== undefined)
.join('&');
const search = this.search.trim();
const searchQueryParam = [
search.length > 0 ? search + ' ' : '',
...searchParams.map(p => this.params[p] ? p + ':"' + this.params[p] + '"' : ''),
].filter(p => p.length > 0).join('');
return [queryParams, 'q=' + searchQueryParam].filter(q => q.length > 2).join('&');
}
get orderBy(): 'newest' | 'relevant' {
return this.params['orderBy'] as 'newest' | 'relevant' ?? 'relevant';
}
set orderBy(value: 'newest' | 'relevant' | null) {
if (!value) {
delete this.params['orderBy'];
} else {
this.params['orderBy'] = value;
}
}
get maxResults(): number {
return 'maxResults' in this.params ? parseInt(this.params['maxResults']) : 10;
}
set maxResults(value: string) {
if (!value || isNaN(parseInt(value))) {
return;
}
this.params['maxResults'] = value;
}
get startIndex(): number {
return 'startIndex' in this.params ? parseInt(this.params['startIndex']) : 10;
}
set startIndex(value: string) {
if (!value || isNaN(parseInt(value))) {
return;
}
this.params['startIndex'] = value;
}
get intitle(): string {
return 'intitle' in this.params ? this.params['intitle'] : null;
}
set intitle(value: string) {
if (!value) {
delete this.params['intitle'];
} else {
this.params['intitle'] = value;
}
}
get inpublisher(): string {
return 'inpublisher' in this.params ? this.params['inpublisher'] : null;
}
set inpublisher(value: string) {
if (!value) {
delete this.params['inpublisher'];
} else {
this.params['inpublisher'] = value;
}
}
get inauthor(): string {
return 'inauthor' in this.params ? this.params['inauthor'] : null;
}
set inauthor(value: string) {
if (!value) {
delete this.params['inauthor'];
} else {
this.params['inauthor'] = value;
}
}
get isbn(): string {
return 'isbn' in this.params ? this.params['isbn'] : null;
}
set isbn(value: string) {
if (!value) {
delete this.params['isbn'];
} else {
this.params['isbn'] = value;
}
}
get subject(): string {
return 'subject' in this.params ? this.params['subject'] : null;
}
set subject(value: string) {
if (!value) {
delete this.params['subject'];
} else {
this.params['subject'] = value;
}
}
previous(pageCount: number = 1): GoogleSearchContext {
if (pageCount > 0)
return this.update(-pageCount);
return this;
}
next(pageCount: number = 1): GoogleSearchContext {
if (pageCount > 0)
return this.update(pageCount);
return this;
}
private update(pageChange: number): GoogleSearchContext {
const resultsPerPage = this.params['maxResults'] ? parseInt(this.params['maxResults']) : 10;
const index = this.params['startIndex'] ? parseInt(this.params['startIndex']) : 0;
const data = { ...this.params };
data['startIndex'] = Math.max(0, index + resultsPerPage * pageChange).toString();
return new GoogleSearchContext(this.search, data);
}
}

View File

@@ -0,0 +1,15 @@
export abstract class SearchContext {
provider: string;
search: string;
params: { [key: string]: string };
constructor(provider: string, search: string, params: { [key: string]: string }) {
this.provider = provider;
this.search = search;
this.params = params;
}
abstract generateQueryParams(): string;
abstract previous(pageCount: number): SearchContext;
abstract next(pageCount: number): SearchContext;
}

View File

@@ -0,0 +1,23 @@
import { GoogleSearchContext } from "./google.search.context";
import { SearchContext } from "./search.context";
export class SimplifiedSearchContext {
values: { [key: string]: string };
constructor(values: { [key: string]: string }) {
this.values = values;
}
toSearchContext(): SearchContext | null {
const provider = this.values['provider']?.toString().toLowerCase();
const search = this.values['search']?.toString();
const valuesCopy = { ...this.values };
delete valuesCopy['provider'];
delete valuesCopy['search'];
if (provider == 'google') {
return new GoogleSearchContext(search, valuesCopy)
}
return null;
}
}

View File

@@ -0,0 +1,16 @@
import { Transform } from "class-transformer";
import { IsAlpha, IsIn, IsNotEmpty, IsString, Length } from "class-validator";
export class BookSearchInputDto {
@IsString()
@IsNotEmpty()
@IsAlpha()
@IsIn(['google'])
@Transform(({ value }) => value.toLowerCase())
provider!: string;
@IsString()
@IsNotEmpty()
@Length(1, 64)
query!: string;
}

View File

@@ -0,0 +1,70 @@
import { Transform } from "class-transformer";
import { IsArray, IsDate, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, ValidateNested } from "class-validator";
export class BookSearchResultDto {
@IsString()
@IsNotEmpty()
providerBookId: string;
@IsString()
@IsOptional()
providerSeriesId: string;
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
desc: string;
@IsNumber()
@IsOptional()
volume: number|null;
@IsString()
@IsNotEmpty()
publisher: string;
@IsArray()
@IsNotEmpty()
@IsString({ each: true })
authors: string[];
@IsObject()
@IsNotEmpty()
industryIdentifiers: { [key: string]: string };
@IsString()
@IsNotEmpty()
provider: string;
@IsDate()
@IsNotEmpty()
@Transform(({ value }) => new Date(value))
publishedAt: Date;
@IsString()
@IsNotEmpty()
language: string;
@IsString()
@IsOptional()
mediaType: string | null;
@IsArray()
@IsString({ each: true })
categories: string[];
@IsString()
@IsNotEmpty()
maturityRating: string;
@IsString()
@IsNotEmpty()
thumbnail: string;
@IsString()
@IsOptional()
url: string;
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GoogleService } from './google.service';
describe('GoogleService', () => {
let service: GoogleService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [GoogleService],
}).compile();
service = module.get<GoogleService>(GoogleService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,138 @@
import { BadRequestException, HttpException, Injectable } from '@nestjs/common';
import { BookSearchResultDto } from '../dto/book-search-result.dto';
import { HttpService } from '@nestjs/axios';
import { catchError, EMPTY, firstValueFrom, map, timeout } from 'rxjs';
import { AxiosError, AxiosResponse } from 'axios';
import { GoogleSearchContext } from '../contexts/google.search.context';
import { PinoLogger } from 'nestjs-pino';
@Injectable()
export class GoogleService {
constructor(
private readonly http: HttpService,
private readonly logger: PinoLogger,
) { }
async searchRaw(searchQuery: string): Promise<BookSearchResultDto[]> {
const queryParams = 'langRestrict=en&printType=books&maxResults=20&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))&q=';
return await firstValueFrom(
this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery)
.pipe(
timeout({ first: 5000 }),
map(value => this.transform(value)),
)
);
}
async search(context: GoogleSearchContext): Promise<BookSearchResultDto[]> {
if (!context) {
return null;
}
const defaultQueryParams = 'printType=books&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))';
const customQueryParams = context.generateQueryParams();
return await firstValueFrom(
this.http.get('https://www.googleapis.com/books/v1/volumes?' + defaultQueryParams + '&' + customQueryParams)
.pipe(
timeout({ first: 5000 }),
map(value => this.transform(value)),
catchError((err: any) => {
if (err instanceof AxiosError) {
if (err.status == 400) {
throw new BadRequestException(err.response.data);
} else if (err.status == 429) {
throw new HttpException(err.response?.data, 429);
}
}
this.logger.error({
class: GoogleService.name,
method: this.search.name,
msg: 'Unknown Google search error.',
error: err,
});
return EMPTY;
})
)
);
}
private transform(response: AxiosResponse): BookSearchResultDto[] {
if (!response.data?.items) {
return [];
}
return response.data.items
.map(item => this.extract(item));
}
private extract(item: any): BookSearchResultDto {
const secure = process.env.WEB_SECURE?.toLowerCase() == 'true';
const result: BookSearchResultDto = {
providerBookId: item.id,
providerSeriesId: item.volumeInfo.seriesInfo?.volumeSeries[0].seriesId,
title: item.volumeInfo.title,
desc: item.volumeInfo.description,
volume: item.volumeInfo.seriesInfo?.bookDisplayNumber ? parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber, 10) : undefined,
publisher: item.volumeInfo.publisher,
authors: item.volumeInfo.authors,
categories: item.volumeInfo.categories ?? [],
mediaType: null,
maturityRating: item.volumeInfo.maturityRating,
industryIdentifiers: item.volumeInfo.industryIdentifiers ? Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => i.type == 'OTHER' ? { [i.identifier.split(':')[0]]: i.identifier.split(':')[1] } : { [i.type]: i.identifier })) : [],
publishedAt: new Date(item.volumeInfo.publishedDate),
language: item.volumeInfo.language,
thumbnail: secure && item.volumeInfo.imageLinks?.thumbnail ? item.volumeInfo.imageLinks.thumbnail.replaceAll('http://', 'https://') : item.volumeInfo.imageLinks?.thumbnail,
url: item.volumeInfo.canonicalVolumeLink,
provider: 'google'
};
const regex = this.getRegexByPublisher(result.publisher);
const match = result.title.match(regex);
if (match?.groups) {
result.title = match.groups['title'].trim();
if (!result.volume || isNaN(result.volume)) {
result.volume = parseInt(match.groups['volume'], 10);
}
}
if (match?.groups && 'media_type' in match.groups) {
result.mediaType = match.groups['media_type'];
} else if (result.categories.includes('Comics & Graphic Novels')) {
result.mediaType = 'Comics & Graphic Novels';
} else if (result.categories.includes('Fiction') || result.categories.includes('Young Adult Fiction')) {
result.mediaType = 'Novel';
} else {
result.mediaType = 'Book';
}
if (result.mediaType) {
if (result.mediaType.toLowerCase() == "light novel") {
result.mediaType = 'Light Novel';
} else if (result.mediaType.toLowerCase() == 'manga') {
result.mediaType = 'Manga';
}
}
return result;
}
private getRegexByPublisher(publisher: string): RegExp {
switch (publisher) {
case 'J-Novel Club':
return /^(?<title>.+?):?(?:\s\((?<media_type>\w+)\))?(?:\sVolume\s(?<volume>\d+))?$/i;
case 'Yen On':
case 'Yen Press':
case 'Yen Press LLC':
return /^(?:(?<title>.+?)(?:,?\sVol\.?\s(?<volume>\d+))(?:\s\((?<media_type>[\w\s]+)\))?)$/i;
case 'Hanashi Media':
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\),?\sVol\.\s(?<volume>\d+)$/i
case 'Regin\'s Chronicles':
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\)(?<subtitle>\:\s.+?)?$/i
default:
return /^(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)$/i;
}
}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ProvidersController } from './providers.controller';
describe('ProvidersController', () => {
let controller: ProvidersController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ProvidersController],
}).compile();
controller = module.get<ProvidersController>(ProvidersController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,21 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ProvidersService } from './providers.service';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { SimplifiedSearchContext } from './contexts/simplified-search-context';
@Controller('providers')
export class ProvidersController {
constructor(
private providers: ProvidersService,
) { }
@UseGuards(JwtAccessGuard)
@Get('search')
async Search(
@Query() context,
) {
const simplified = new SimplifiedSearchContext(context);
const searchContext = simplified.toSearchContext();
return await this.providers.search(searchContext);
}
}

View File

@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { GoogleService } from './google/google.service';
import { ProvidersService } from './providers.service';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ProvidersController } from './providers.controller';
import { BooksService } from 'src/books/books.service';
@Module({
imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (config: ConfigService) => ({
timeout: config.get('HTTP_TIMEOUT') ?? 5000,
maxRedirects: config.get('HTTP_MAX_REDIRECTS') ?? 5,
}),
}),
],
exports: [
ProvidersService,
GoogleService,
],
providers: [GoogleService, ProvidersService],
controllers: [ProvidersController]
})
export class ProvidersModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ProvidersService } from './providers.service';
describe('ProvidersService', () => {
let service: ProvidersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ProvidersService],
}).compile();
service = module.get<ProvidersService>(ProvidersService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { GoogleService } from './google/google.service';
import { BookSearchResultDto } from './dto/book-search-result.dto';
import { GoogleSearchContext } from './contexts/google.search.context';
import { SearchContext } from './contexts/search.context';
@Injectable()
export class ProvidersService {
constructor(
private readonly google: GoogleService,
) { }
generateSearchContext(providerName: string, searchQuery: string): SearchContext | null {
let params: { [key: string]: string } = {};
if (providerName.toLowerCase() == 'google') {
return new GoogleSearchContext(searchQuery, params);
}
return null;
}
async searchRaw(providerName: string, searchQuery: string): Promise<BookSearchResultDto[]> {
switch (providerName.toLowerCase()) {
case 'google':
return await this.google.searchRaw(searchQuery);
default:
throw Error('Invalid provider name.');
}
}
async search(context: SearchContext): Promise<BookSearchResultDto[]> {
switch (context.provider.toLowerCase()) {
case 'google':
return await this.google.search(context as GoogleSearchContext);
default:
throw Error('Invalid provider name.');
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
import { SeriesDto } from './series.dto';
import { UUID } from 'crypto';
export class SeriesSubscriptionDto extends SeriesDto {
@IsUUID()
@IsNotEmpty()
userId: UUID;
}

View File

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

View File

@@ -0,0 +1,9 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
import { UUID } from 'crypto';
import { CreateSeriesDto } from './create-series.dto';
export class UpdateSeriesDto extends CreateSeriesDto {
@IsUUID()
@IsNotEmpty()
seriesId: UUID;
}

View File

@@ -0,0 +1,29 @@
import { UUID } from 'crypto';
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { SeriesEntity } from './series.entity';
@Entity("series_subscriptions")
export class SeriesSubscriptionEntity {
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
readonly userId: UUID;
@PrimaryColumn({ name: 'provider_series_id', type: 'text' })
providerSeriesId: string;
@PrimaryColumn({ name: 'provider', type: 'text' })
provider: string;
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date;
@OneToOne(type => SeriesEntity, series => series.subscriptions)
@JoinColumn([{
name: 'provider_series_id',
referencedColumnName: 'providerSeriesId',
},
{
name: 'provider',
referencedColumnName: 'provider',
}])
series: SeriesSubscriptionEntity[];
}

View File

@@ -0,0 +1,32 @@
import { UUID } from 'crypto';
import { BookEntity } from 'src/books/entities/book.entity';
import { Column, Entity, OneToMany, PrimaryColumn, Unique } from 'typeorm';
import { SeriesSubscriptionEntity } from './series-subscription.entity';
@Entity("series")
@Unique(['provider', 'providerSeriesId'])
export class SeriesEntity {
@PrimaryColumn({ name: 'series_id', type: 'uuid' })
readonly seriesId: UUID;
@Column({ name: 'provider_series_id', type: 'text', nullable: true })
providerSeriesId: string;
@Column({ name: 'series_title', type: 'text', nullable: false })
title: string;
@Column({ name: 'media_type', type: 'text', nullable: true })
mediaType: string;
@Column({ name: 'provider', type: 'text', nullable: false })
provider: string;
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date;
@OneToMany(type => BookEntity, book => [book.provider, book.providerSeriesId])
volumes: BookEntity[];
@OneToMany(type => SeriesSubscriptionEntity, subscription => [subscription.provider, subscription.providerSeriesId])
subscriptions: SeriesSubscriptionEntity[];
}

View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { SeriesService } from './series.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SeriesEntity } from './entities/series.entity';
import { HttpModule } from '@nestjs/axios';
import { ProvidersModule } from 'src/providers/providers.module';
import { SeriesSubscriptionEntity } from './entities/series-subscription.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
SeriesEntity,
SeriesSubscriptionEntity,
]),
HttpModule,
ProvidersModule,
],
controllers: [],
exports: [
SeriesService,
],
providers: [SeriesService]
})
export class SeriesModule { }

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SeriesService } from './series.service';
describe('SeriesService', () => {
let service: SeriesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SeriesService],
}).compile();
service = module.get<SeriesService>(SeriesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { SeriesEntity } from './entities/series.entity';
import { Repository } from 'typeorm';
import { CreateSeriesDto } from './dto/create-series.dto';
import { SeriesDto } from './dto/series.dto';
import { SeriesSubscriptionEntity } from './entities/series-subscription.entity';
import { UUID } from 'crypto';
import { SeriesSubscriptionDto } from './dto/series-subscription.dto';
@Injectable()
export class SeriesService {
constructor(
@InjectRepository(SeriesEntity)
private readonly seriesRepository: Repository<SeriesEntity>,
@InjectRepository(SeriesSubscriptionEntity)
private readonly seriesSubscriptionRepository: Repository<SeriesSubscriptionEntity>,
) { }
async addSeries(series: CreateSeriesDto) {
return await this.seriesRepository.insert(series);
}
async addSeriesSubscription(series: SeriesSubscriptionDto) {
return await this.seriesSubscriptionRepository.insert(series);
}
async deleteSeries(series: SeriesDto) {
return await this.seriesRepository.delete(series);
}
async deleteSeriesSubscription(subscription: SeriesSubscriptionDto) {
return await this.seriesSubscriptionRepository.delete(subscription);
}
async getSeries(series: SeriesDto) {
return await this.seriesRepository.findOne({
where: series
});
}
async getAllSeries() {
return await this.seriesRepository.find()
}
async getSeriesSubscribedBy(userId: UUID) {
return await this.seriesRepository.createQueryBuilder('s')
.select(['s.seriesId', 's.providerSeriesId', 's.provider', 's.title', 's.mediaType', 's.addedAt'])
.innerJoinAndMapOne('s.subscriptions',
qb => qb
.select(['subscription.provider', 'subscription.provider_series_id', 'subscription.user_id'])
.from(SeriesSubscriptionEntity, 'subscription'),
'ss', `"ss"."subscription_provider" = "s"."provider" AND "ss"."provider_series_id" = "s"."provider_series_id"`)
.where(`ss.user_id = :id`, { id: userId })
.getMany();
}
async updateSeries(series: CreateSeriesDto) {
return await this.seriesRepository.upsert(series, ['provider', 'providerSeriesId']);
}
}

View File

@@ -0,0 +1,15 @@
export enum BookOriginType {
AUTHOR = 1,
ILLUSTRATOR = 2,
CATEGORY = 10,
LANGUAGE = 11,
MEDIA_TYPE = 12,
MATURITY_RATING = 20,
PROVIDER_THUMBNAIL = 30,
PROVIDER_URL = 31,
// 4x - Ratings
ISBN_10 = 40,
ISBN_13 = 41,
// 1xx - User-defined
TAGS = 100,
}

View File

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

View File

@@ -1,9 +1,10 @@
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { UUID } from "crypto"; import { UUID } from "crypto";
import { BookStatusEntity } from 'src/books/entities/book-status.entity';
import { BigIntTransformer } from 'src/shared/transformers/bigint'; import { BigIntTransformer } from 'src/shared/transformers/bigint';
import { StringToLowerCaseTransformer } from 'src/shared/transformers/string'; import { StringToLowerCaseTransformer } from 'src/shared/transformers/string';
import { BeforeInsert, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
@Entity({ @Entity({
name: 'users' name: 'users'
@@ -18,17 +19,20 @@ export class UserEntity {
@Column({ name: 'user_name', nullable: false }) @Column({ name: 'user_name', nullable: false })
userName: string; userName: string;
@Column({ nullable: false }) @Column({ name: 'password', nullable: false })
password: string; password: string;
@Column({ type: 'bigint', nullable: false, transformer: BigIntTransformer }) @Column({ name: 'salt', type: 'bigint', nullable: false, transformer: BigIntTransformer })
salt: BigInt; salt: BigInt;
@Column({ name: 'is_admin', nullable: false }) @Column({ name: 'is_admin', nullable: false })
isAdmin: boolean; isAdmin: boolean;
@Column({ name: 'date_joined', type: 'timestamptz', nullable: false }) @Column({ name: 'joined_at', type: 'timestamptz', nullable: false })
dateJoined: Date; joinedAt: Date;
@OneToMany(type => BookStatusEntity, bookStatus => bookStatus.userId)
bookStatuses: BookStatusEntity[];
@BeforeInsert() @BeforeInsert()
async hashPassword() { async hashPassword() {

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { UserEntity } from './users.entity'; import { UserEntity } from './entities/users.entity';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';

View File

@@ -2,16 +2,16 @@ import * as argon2 from 'argon2';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { UserEntity } from './users.entity'; import { UserEntity } from './entities/users.entity';
import { LoginUserDto } from './dto/login-user.dto';
import { UUID } from 'crypto'; import { UUID } from 'crypto';
import { LoginDto } from 'src/auth/dto/login.dto';
class UserDto { class UserDto {
userId: string; userId: UUID;
userLogin: string; userLogin: string;
userName: string; userName: string;
isAdmin: boolean; isAdmin: boolean;
dateJoined: Date; joinedAt: Date;
} }
@Injectable() @Injectable()
@@ -28,19 +28,19 @@ export class UsersService {
userLogin: u.userLogin, userLogin: u.userLogin,
userName: u.userName, userName: u.userName,
isAdmin: u.isAdmin, isAdmin: u.isAdmin,
dateJoined: u.dateJoined, joinedAt: u.joinedAt,
})); }));
} }
async findOne({ username, password }: LoginUserDto): Promise<UserEntity> { async findOne(loginDetails: LoginDto): Promise<UserEntity> {
const user = await this.userRepository.findOneBy({ userLogin: username }); const user = await this.userRepository.findOneBy({ userLogin: loginDetails.user_login });
if (!user) { if (!user) {
// TODO: force an argon2.verify() to occur here. // TODO: force an argon2.verify() to occur here.
return null; return null;
} }
const buffer = Buffer.concat([ const buffer = Buffer.concat([
Buffer.from(password, 'utf8'), Buffer.from(loginDetails.password, 'utf8'),
Buffer.from(user.salt.toString(16), 'hex'), 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>

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