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