Compare commits

..

29 Commits

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

View File

@@ -11,6 +11,8 @@ DROP TABLE IF EXISTS book_origins;
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS series_subscriptions;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS books;
@@ -23,6 +25,7 @@ CREATE TABLE
-- 3rd party id for this series.
provider_series_id text,
series_title text NOT NULL,
media_type text,
-- 3rd party used to fetch the data for this series.
provider varchar(12) NOT NULL,
added_at timestamp default NULL,
@@ -51,9 +54,8 @@ CREATE TABLE
published_at timestamp default NULL,
added_at timestamp default NULL,
PRIMARY KEY (book_id),
-- FOREIGN KEY (series_id) REFERENCES series (series_id),
FOREIGN KEY (provider, provider_series_id) REFERENCES series (provider, provider_series_id) ON DELETE CASCADE,
UNIQUE NULLS NOT DISTINCT (provider_series_id, provider_book_id)
UNIQUE NULLS NOT DISTINCT (provider_series_id, provider_book_id, book_volume)
);
ALTER TABLE books
@@ -61,7 +63,7 @@ ALTER COLUMN added_at
SET DEFAULT now ();
-- CREATE INDEX books_series_id_idx ON books (series_id);
CREATE INDEX books_provider_series_id_idx ON books (provider_series_id);
CREATE INDEX books_provider_provider_series_id_idx ON books (provider, provider_series_id);
-- CREATE INDEX books_isbn_idx ON books USING HASH (isbn);
CREATE INDEX books_book_title_idx ON books (book_title);
@@ -111,7 +113,7 @@ CREATE TABLE
book_statuses (
user_id uuid,
book_id uuid,
state varchar(12),
state smallint,
added_at timestamp default NULL,
modified_at timestamp default NULL,
PRIMARY KEY (user_id, book_id),
@@ -128,7 +130,7 @@ CREATE INDEX book_statuses_user_id_login_idx ON users (user_id);
CREATE TABLE
series_subscriptions (
user_id uuid,
provider text,
provider varchar(12) NOT NULL,
provider_series_id text,
added_at timestamp default NULL,
PRIMARY KEY (user_id, provider, provider_series_id),

View File

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

View File

@@ -10,6 +10,7 @@
"license": "UNLICENSED",
"dependencies": {
"@nestjs/axios": "^4.0.0",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^10.0.0",
@@ -19,14 +20,17 @@
"@nestjs/typeorm": "^11.0.0",
"argon2": "^0.41.1",
"axios": "^1.7.9",
"bullmq": "^5.41.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"moment": "^2.30.1",
"nestjs-pino": "^4.3.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.13.1",
"pino-http": "^10.4.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
@@ -101,6 +105,16 @@
}
}
},
"node_modules/@angular-devkit/core/node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/@angular-devkit/schematics": {
"version": "17.3.11",
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz",
@@ -213,6 +227,16 @@
"node": ">=0.12.0"
}
},
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -937,6 +961,12 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@ioredis/commands": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1574,6 +1604,84 @@
"node": ">=8"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@nestjs/axios": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.0.tgz",
@@ -1585,6 +1693,34 @@
"rxjs": "^7.0.0"
}
},
"node_modules/@nestjs/bull-shared": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.2.tgz",
"integrity": "sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/bullmq": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.2.tgz",
"integrity": "sha512-Lq6lGpKkETsm0RDcUktlzsthFoE3A5QTMp2FwPi1eztKqKD6/90KS1TcnC9CJFzjpUaYnQzIMrlNs55e+/wsHA==",
"license": "MIT",
"dependencies": {
"@nestjs/bull-shared": "^11.0.2",
"tslib": "2.8.1"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
"bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@nestjs/cli": {
"version": "10.4.9",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
@@ -2424,17 +2560,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz",
"integrity": "sha512-ll1StnKtBigWIGqvYDVuDmXJHVH4zLVot1yQ4fJtLpL7qacwkxJc1T0bptqw+miBQ/QfUbhl1TcQ4accW5KUyA==",
"version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz",
"integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.24.1",
"@typescript-eslint/type-utils": "8.24.1",
"@typescript-eslint/utils": "8.24.1",
"@typescript-eslint/visitor-keys": "8.24.1",
"@typescript-eslint/scope-manager": "8.25.0",
"@typescript-eslint/type-utils": "8.25.0",
"@typescript-eslint/utils": "8.25.0",
"@typescript-eslint/visitor-keys": "8.25.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@@ -2454,16 +2590,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.1.tgz",
"integrity": "sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==",
"version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz",
"integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.24.1",
"@typescript-eslint/types": "8.24.1",
"@typescript-eslint/typescript-estree": "8.24.1",
"@typescript-eslint/visitor-keys": "8.24.1",
"@typescript-eslint/scope-manager": "8.25.0",
"@typescript-eslint/types": "8.25.0",
"@typescript-eslint/typescript-estree": "8.25.0",
"@typescript-eslint/visitor-keys": "8.25.0",
"debug": "^4.3.4"
},
"engines": {
@@ -2479,14 +2615,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz",
"integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==",
"version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz",
"integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.24.1",
"@typescript-eslint/visitor-keys": "8.24.1"
"@typescript-eslint/types": "8.25.0",
"@typescript-eslint/visitor-keys": "8.25.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2497,14 +2633,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.1.tgz",
"integrity": "sha512-/Do9fmNgCsQ+K4rCz0STI7lYB4phTtEXqqCAs3gZW0pnK7lWNkvWd5iW545GSmApm4AzmQXmSqXPO565B4WVrw==",
"version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz",
"integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.24.1",
"@typescript-eslint/utils": "8.24.1",
"@typescript-eslint/typescript-estree": "8.25.0",
"@typescript-eslint/utils": "8.25.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.0.1"
},
@@ -2521,9 +2657,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz",
"integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==",
"version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz",
"integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2535,14 +2671,14 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz",
"integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==",
"version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz",
"integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.24.1",
"@typescript-eslint/visitor-keys": "8.24.1",
"@typescript-eslint/types": "8.25.0",
"@typescript-eslint/visitor-keys": "8.25.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -2562,16 +2698,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz",
"integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==",
"version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz",
"integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.24.1",
"@typescript-eslint/types": "8.24.1",
"@typescript-eslint/typescript-estree": "8.24.1"
"@typescript-eslint/scope-manager": "8.25.0",
"@typescript-eslint/types": "8.25.0",
"@typescript-eslint/typescript-estree": "8.25.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2586,13 +2722,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz",
"integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==",
"version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz",
"integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.24.1",
"@typescript-eslint/types": "8.25.0",
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
@@ -3068,6 +3204,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
@@ -3425,6 +3570,21 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/bullmq": {
"version": "5.41.7",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.41.7.tgz",
"integrity": "sha512-eZbKJSx15bflfzKRiR+dKeLTr/M/YKb4cIp73OdU79PEMHQ6aEFUtbG6R+f0KvLLznI/O01G581U2Eqli6S2ew==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.9.0",
"ioredis": "^5.4.1",
"msgpackr": "^1.11.2",
"node-abort-controller": "^3.1.1",
"semver": "^7.5.4",
"tslib": "^2.0.0",
"uuid": "^9.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -3811,6 +3971,15 @@
"node": ">=0.8"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -4062,6 +4231,18 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4171,6 +4352,15 @@
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -4190,6 +4380,16 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -4323,9 +4523,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.102",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.102.tgz",
"integrity": "sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==",
"version": "1.5.104",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.104.tgz",
"integrity": "sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g==",
"dev": true,
"license": "ISC"
},
@@ -4943,6 +5143,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-redact": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz",
"integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -5137,12 +5346,12 @@
}
},
"node_modules/foreground-child": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.0",
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
@@ -5326,17 +5535,17 @@
}
},
"node_modules/get-intrinsic": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
"integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.0",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
@@ -5724,6 +5933,30 @@
"node": ">=12.0.0"
}
},
"node_modules/ioredis": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.5.0.tgz",
"integrity": "sha512-7CutT89g23FfSa8MDoIFs2GYYa0PaNiW/OrT+nRyjRXHDZd17HmIgy+reOQ/yhh72NznNjGuS8kbCAcA4Ro4mw==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -6962,12 +7195,24 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -7045,6 +7290,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
@@ -7287,6 +7541,37 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz",
"integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multer": {
"version": "1.4.4-lts.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz",
@@ -7346,17 +7631,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/nestjs-pino": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.3.1.tgz",
"integrity": "sha512-Mym5k8X0prO4dqZoMC11EsZq3lggeJg9Z3gTUl4JvRXGkCz2Owu3ZUOiitDdX1znowPWVXOse/1Q+kufi2SlZA==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": ">= 14"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"pino": "^7.5.0 || ^8.0.0 || ^9.0.0",
"pino-http": "^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz",
"integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==",
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.1.tgz",
"integrity": "sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
@@ -7403,6 +7703,21 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -7461,6 +7776,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -7896,6 +8220,55 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pino": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz",
"integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0",
"fast-redact": "^3.1.1",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^4.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-http": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.4.0.tgz",
"integrity": "sha512-vjQsKBE+VN1LVchjbfLE7B6nBeGASZNRNKsR68VS0DolTm5R3zo+47JX1wjm0O96dcbvA7vnqt8YqOWlG5nN0w==",
"license": "MIT",
"dependencies": {
"get-caller-file": "^2.0.5",
"pino": "^9.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz",
"integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==",
"license": "MIT"
},
"node_modules/pirates": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
@@ -8035,9 +8408,9 @@
}
},
"node_modules/prettier": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz",
"integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==",
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz",
"integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -8097,6 +8470,22 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
"integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -8193,6 +8582,12 @@
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -8281,6 +8676,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -8510,9 +8935,9 @@
}
},
"node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
@@ -8538,6 +8963,15 @@
],
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -8841,6 +9275,15 @@
"node": ">=8"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@@ -8911,6 +9354,12 @@
"node": ">=8"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -9355,6 +9804,15 @@
"node": ">=0.8"
}
},
"node_modules/thread-stream": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@@ -9434,9 +9892,9 @@
}
},
"node_modules/ts-jest": {
"version": "29.2.5",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz",
"integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==",
"version": "29.2.6",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.6.tgz",
"integrity": "sha512-yTNZVZqc8lSixm+QGVFcPe6+yj7+TWZwIesuOWvfcn4B9bz5x4NDzVCQQjOs7Hfouu36aEqfEbo9Qpo+gq8dDg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9447,7 +9905,7 @@
"json5": "^2.2.3",
"lodash.memoize": "^4.1.2",
"make-error": "^1.3.6",
"semver": "^7.6.3",
"semver": "^7.7.1",
"yargs-parser": "^21.1.1"
},
"bin": {

View File

@@ -21,6 +21,7 @@
},
"dependencies": {
"@nestjs/axios": "^4.0.0",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^10.0.0",
@@ -30,14 +31,17 @@
"@nestjs/typeorm": "^11.0.0",
"argon2": "^0.41.1",
"axios": "^1.7.9",
"bullmq": "^5.41.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"moment": "^2.30.1",
"nestjs-pino": "^4.3.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.13.1",
"pino-http": "^10.4.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",

View File

@@ -11,10 +11,13 @@ import { UsersModule } from './users/users.module';
import { UserEntity } from './users/entities/users.entity';
import { AuthModule } from './auth/auth.module';
import { LoggerModule } from 'nestjs-pino';
import { serialize_token, serialize_user_short, serialize_user_long, serialize_res, serialize_req } from './logging.serializers';
import { serialize_token, serialize_user_short, serialize_user_long, serialize_res, serialize_req, serialize_job } from './logging.serializers';
import { BooksModule } from './books/books.module';
import { ProvidersModule } from './providers/providers.module';
import { SeriesModule } from './series/series.module';
import { LibraryModule } from './library/library.module';
import { BullModule } from '@nestjs/bullmq';
import { AssetModule } from './asset/asset.module';
@Module({
imports: [
@@ -23,6 +26,17 @@ import { SeriesModule } from './series/series.module';
imports: [ConfigModule],
useClass: DatabaseOptions
}),
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: {
host: config.get('REDIS_HOST') ?? 'localhost',
port: config.get('REDIS_PORT') ?? 6379,
password: config.get('REDIS_PASSWORD'),
}
})
}),
TypeOrmModule.forFeature([UserEntity]),
UsersModule,
AuthModule,
@@ -38,12 +52,14 @@ import { SeriesModule } from './series/series.module';
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),
},
@@ -58,7 +74,10 @@ import { SeriesModule } from './series/series.module';
}),
BooksModule,
ProvidersModule,
SeriesModule
SeriesModule,
LibraryModule,
ConfigModule,
AssetModule,
],
controllers: [AppController],
providers: [AppService, UsersService],

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { JwtService } from '@nestjs/jwt';
import { UserEntity } from 'src/users/entities/users.entity';
import { ConfigService } from '@nestjs/config';
import { PinoLogger } from 'nestjs-pino';
import { AccessTokenDto } from './dto/access-token.dto';
@Injectable()
export class AuthAccessService {
@@ -13,7 +14,7 @@ export class AuthAccessService {
private logger: PinoLogger,
) { }
async generate(user: UserEntity) {
async generate(user: UserEntity): Promise<AccessTokenDto> {
const now = new Date();
const limit = parseInt(this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_EXPIRATION_MS'));
const expiration = moment(now).add(limit, 'ms').toDate();
@@ -45,4 +46,12 @@ export class AuthAccessService {
exp: expiration.getTime(),
}
}
async verify(token: string) {
return await this.jwts.verifyAsync(token,
{
secret: this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET')
}
);
}
}

View File

@@ -1,5 +1,4 @@
import { Controller, Request, Post, UseGuards, Body, Res, Delete, Patch } from '@nestjs/common';
import { LoginAuthGuard } from './guards/login-auth.guard';
import { Controller, Request, Post, UseGuards, Body, Res, Delete, Patch, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersService } from 'src/users/users.service';
import { RegisterUserDto } from './dto/register-user.dto';
@@ -9,7 +8,10 @@ import { OfflineGuard } from './guards/offline.guard';
import { UserEntity } from 'src/users/entities/users.entity';
import { QueryFailedError } from 'typeorm';
import { PinoLogger } from 'nestjs-pino';
import { JwtAccessGuard } from './guards/jwt-access.guard';
import { LoginDto } from './dto/login.dto';
import { AuthenticationDto } from './dto/authentication.dto';
import { AppConfig } from 'src/asset/config/app-config';
import { JwtMixedGuard } from './guards/jwt-mixed.guard';
@Controller('auth')
export class AuthController {
@@ -19,16 +21,17 @@ export class AuthController {
private logger: PinoLogger,
) { }
@UseGuards(LoginAuthGuard)
@UseGuards(OfflineGuard)
@Post('login')
async login(
@Request() req,
@Res({ passthrough: true }) response: Response,
@Body() body: LoginDto,
) {
let data: AuthenticationDto | null;
try {
data = await this.auth.login(req.user);
if (!data.access_token || !data.refresh_token || !data.refresh_exp) {
data = await this.auth.login(body);
if (!data.access_token || body.remember_me && (!data.refresh_token || !data.refresh_exp)) {
response.statusCode = 500;
return {
success: false,
@@ -44,6 +47,14 @@ export class AuthController {
error: err,
});
if (err instanceof UnauthorizedException) {
response.statusCode = 401;
return {
success: false,
error_message: 'Invalid credentials.',
};
}
response.statusCode = 500;
return {
success: false,
@@ -58,12 +69,14 @@ export class AuthController {
sameSite: 'strict',
});
if (body.remember_me) {
response.cookie('Refresh', data.refresh_token, {
httpOnly: true,
secure: true,
expires: new Date(data.refresh_exp),
sameSite: 'strict',
});
}
this.logger.info({
class: AuthController.name,
@@ -71,6 +84,7 @@ export class AuthController {
user: req.user,
access_token: data.access_token,
refresh_token: data.refresh_token,
remember_me: body.remember_me,
msg: 'User logged in.',
});
@@ -79,7 +93,7 @@ export class AuthController {
};
}
@UseGuards(JwtAccessGuard)
@UseGuards(JwtMixedGuard)
@Delete('login')
async logout(
@Request() req,
@@ -91,19 +105,19 @@ export class AuthController {
response.clearCookie('Authentication');
response.clearCookie('Refresh');
if (!refreshToken || !await this.auth.revoke(req.user.userId, refreshToken)) {
if (!accessToken && !refreshToken && !await this.auth.revoke(req.user.userId, refreshToken)) {
// User has already logged off.
this.logger.info({
class: AuthController.name,
method: this.login.name,
method: this.logout.name,
user: req.user,
msg: 'User has already logged off via ' + (!refreshToken ? 'cookies' : 'database'),
msg: 'User has already logged off based on ' + (!refreshToken ? 'cookies' : 'database'),
});
response.statusCode = 400;
return {
success: false,
error_message: 'User has already logged off.'
error_message: 'User has already logged off.',
};
}
@@ -127,14 +141,31 @@ export class AuthController {
) {
this.logger.info({
class: AuthController.name,
method: this.login.name,
method: this.refresh.name,
user: req.user,
refresh_token: req.cookies.Refresh,
msg: 'User logged in.',
msg: 'Attempting to renew access token.',
});
const refreshToken = req.cookies.Refresh;
const data = await this.auth.renew(req.user, refreshToken);
const results = await this.auth.verify(req.cookies.Authentication, req.cookies.Refresh);
if (results.validation === false) {
this.logger.info({
class: AuthController.name,
method: this.refresh.name,
user: req.user,
refresh_token: req.cookies.Refresh,
msg: 'Refresh token is invalid. Access token is not refreshing.',
});
response.statusCode = 400;
return {
success: false,
error_message: 'Refresh token is invalid.',
};
}
const data = await this.auth.renew(req.user);
response.cookie('Authentication', data.access_token, {
httpOnly: true,
@@ -150,22 +181,6 @@ export class AuthController {
msg: 'Updated Authentication cookie for access token.',
});
if (data.refresh_token != refreshToken) {
response.cookie('Refresh', data.refresh_token, {
httpOnly: true,
secure: true,
expires: new Date(data.refresh_exp),
sameSite: 'strict',
});
this.logger.debug({
class: AuthController.name,
method: this.refresh.name,
user: req.user,
refresh_token: data.refresh_token,
msg: 'Updated Refresh cookie for refresh token.',
});
}
return { success: true };
}
@@ -176,6 +191,14 @@ export class AuthController {
@Res({ passthrough: true }) response: Response,
@Body() body: RegisterUserDto,
) {
if (!AppConfig.features.registration) {
response.statusCode = 404;
return {
success: false,
error_message: 'Registration disabled.',
};
}
let user: UserEntity | null;
let data: AuthenticationDto | null;
try {
@@ -197,7 +220,7 @@ export class AuthController {
msg: 'Failed to register due to duplicate userLogin.',
});
response.statusCode = 400;
response.statusCode = 409;
return {
success: false,
error_message: 'Username already exist.',
@@ -220,8 +243,12 @@ export class AuthController {
}
try {
data = await this.auth.login(user);
if (!data.access_token || !data.refresh_token || !data.refresh_exp) {
data = await this.auth.login({
user_login: body.user_login,
password: body.password,
remember_me: false,
});
if (!data.access_token) {
this.logger.error({
class: AuthController.name,
method: this.register.name,
@@ -246,6 +273,15 @@ export class AuthController {
error: err,
});
// This should never happen...
if (err instanceof UnauthorizedException) {
response.statusCode = 401;
return {
success: false,
error_message: 'Invalid credentials.',
};
}
response.statusCode = 500;
return {
success: false,
@@ -260,15 +296,31 @@ export class AuthController {
sameSite: 'strict',
});
response.cookie('Refresh', data.refresh_token, {
httpOnly: true,
secure: true,
expires: new Date(data.refresh_exp),
sameSite: 'strict',
});
return {
success: true,
};
}
@Post('validate')
async validate(
@Request() req,
@Res({ passthrough: true }) response: Response,
) {
try {
const accessToken = req.cookies['Authentication'];
const refreshToken = req.cookies['Refresh'];
const verification = await this.auth.verify(accessToken, refreshToken);
return {
success: true,
...verification,
};
} catch (err) {
response.statusCode = 500;
return {
success: false,
error_message: err,
};
}
}
}

View File

@@ -2,12 +2,11 @@ import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from 'src/users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LoginStrategy } from './strategies/login.strategy';
import { AuthController } from './auth.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { JwtOptions } from './jwt.options';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtAccessStrategy } from './strategies/jwt-access.strategy';
import { AuthRefreshService } from './auth.refresh.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthRefreshTokenEntity } from './entities/auth.refresh-token.entity';
@@ -35,9 +34,8 @@ import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
AuthAccessService,
AuthRefreshService,
AuthService,
JwtStrategy,
JwtAccessStrategy,
JwtRefreshStrategy,
LoginStrategy,
],
controllers: [AuthController]
})

View File

@@ -1,6 +1,6 @@
import * as crypto from 'crypto';
import * as moment from "moment";
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
@@ -20,59 +20,11 @@ export class AuthRefreshService {
private logger: PinoLogger,
) { }
async generate(user: UserEntity, refreshToken?: string) {
let expiration: Date | null = null;
if (refreshToken) {
const token = await this.get(refreshToken, user.userId);
if (!token) {
this.logger.warn({
class: AuthRefreshService.name,
method: this.generate.name,
user,
refresh_token: refreshToken,
msg: 'Refresh token given is invalid.',
});
throw new UnauthorizedException('Invalid refresh token.');
}
if (token.exp.getTime() < new Date().getTime()) {
this.logger.warn({
class: AuthRefreshService.name,
method: this.generate.name,
user,
refresh_token: refreshToken,
exp: expiration,
msg: 'Refresh token given has expired.',
});
throw new UnauthorizedException('Invalid refresh token.');
}
expiration = token.exp;
}
// Generate new refresh token if either:
// - no previous token exists;
// - token has reached expiration threshold;
// - token has expired.
async generate(user: UserEntity) {
const now = new Date();
const expirationTime = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_MS'));
const threshhold = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_THRESHHOLD_MS'));
if (!refreshToken || expirationTime - (expiration.getTime() - now.getTime()) > threshhold) {
let deletionTask = null;
if (refreshToken) {
deletionTask = this.revoke(user.userId, refreshToken);
this.logger.debug({
class: AuthRefreshService.name,
method: this.generate.name,
user,
refresh_token: refreshToken,
exp: expiration,
msg: 'Deleted previous refresh token.',
});
}
expiration = moment(now).add(expirationTime, 'ms').toDate();
refreshToken = await this.jwts.signAsync(
const expiration = moment(now).add(expirationTime, 'ms').toDate();
const refreshToken = await this.jwts.signAsync(
{
username: user.userLogin,
sub: user.userId,
@@ -109,9 +61,6 @@ export class AuthRefreshService {
msg: 'Inserted the new refresh token into the database.',
});
await deletionTask;
}
return {
refresh_token: refreshToken,
exp: expiration.getTime(),
@@ -155,4 +104,14 @@ export class AuthRefreshService {
const refresh = await this.get(refreshToken, userId);
return refresh && refresh.exp.getTime() > new Date().getTime();
}
async verify(
refreshToken: string
): Promise<any> {
return await this.jwts.verifyAsync(refreshToken,
{
secret: this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_SECRET'),
}
);
}
}

View File

@@ -1,9 +1,13 @@
import { Injectable } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserEntity } from 'src/users/entities/users.entity';
import { UsersService } from 'src/users/users.service';
import { AuthRefreshService } from './auth.refresh.service';
import { AuthAccessService } from './auth.access.service';
import { UUID } from 'crypto';
import { AuthenticationDto } from './dto/authentication.dto';
import { LoginDto } from './dto/login.dto';
import { AccessTokenDto } from './dto/access-token.dto';
import { TokenExpiredError } from '@nestjs/jwt';
@Injectable()
export class AuthService {
@@ -14,33 +18,127 @@ export class AuthService {
) { }
async login(user: UserEntity): Promise<AuthenticationDto> {
return this.renew(user, null);
async login(
loginDetails: LoginDto
): Promise<AuthenticationDto> {
const user = await this.users.findOne(loginDetails);
if (!user) {
throw new UnauthorizedException();
}
const access_token = await this.accessTokens.generate(user);
if (!loginDetails.remember_me) {
return {
...access_token,
refresh_token: null,
refresh_exp: null,
}
}
const refresh_token = await this.refreshTokens.generate(user);
return {
...access_token,
refresh_token: refresh_token.refresh_token,
refresh_exp: refresh_token.exp,
}
}
async renew(
user: UserEntity,
): Promise<AccessTokenDto> {
return await this.accessTokens.generate(user);
}
async validate(
username: string,
password: string,
): Promise<UserEntity | null> {
return await this.users.findOne({ username, password });
return await this.users.findOne({ user_login: username, password, remember_me: false });
}
async renew(
user: UserEntity,
refresh_token: string | null
): Promise<AuthenticationDto | null> {
const new_refresh_data = await this.refreshTokens.generate(user, refresh_token);
const access_token = await this.accessTokens.generate(user);
async verify(
accessToken: string,
refreshToken: string
): Promise<{ validation: boolean, userId: UUID | null, username: string | null }> {
let access: any = null;
let refresh: any = null;
if (accessToken) {
try {
access = await this.accessTokens.verify(accessToken);
} catch (err) {
if (!(err instanceof TokenExpiredError)) {
return {
validation: false,
userId: null,
username: null,
};
}
}
if (access && (!access.username || !access.sub)) {
return {
validation: false,
userId: null,
username: null,
};
}
}
if (refreshToken) {
try {
refresh = await this.refreshTokens.verify(refreshToken);
} catch (err) {
return {
validation: false,
userId: null,
username: null,
};
}
if (!refresh.username || !refresh.sub) {
return {
validation: false,
userId: null,
username: null,
};
}
}
if (!access && !refresh) {
return {
validation: false,
userId: null,
username: null,
};
} else if (!access && refresh) {
return {
validation: null,
userId: null,
username: null,
};
} else if (access && refresh) {
if (access.username != refresh.username || access.sub != refresh.sub) {
return {
validation: false,
userId: null,
username: null,
};
}
}
return {
...access_token,
refresh_token: new_refresh_data.refresh_token,
refresh_exp: new_refresh_data.exp,
}
validation: true,
userId: (access ?? refresh).sub,
username: (access ?? refresh).username,
};
}
async revoke(userId: UUID, refreshToken: string): Promise<boolean> {
async revoke(
userId: UUID,
refreshToken: string
): Promise<boolean> {
const res = await this.refreshTokens.revoke(userId, refreshToken);
return res?.affected === 1
return res?.affected === 1;
}
}

View File

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

View File

@@ -1,4 +1,4 @@
class AuthenticationDto {
export class AuthenticationDto {
access_token: string;
exp: number;
refresh_token: string | null;

View File

@@ -0,0 +1,19 @@
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
@IsNotEmpty()
@MinLength(3)
@MaxLength(24)
readonly user_login: string;
@IsString()
@IsNotEmpty()
@MinLength(8)
@MaxLength(128)
readonly password: string;
@IsBoolean()
@IsOptional()
readonly remember_me: boolean;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,94 +0,0 @@
import { Body, Controller, Get, Post, Put, Request, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { BooksService } from './books.service';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { UpdateBookOriginDto } from './dto/update-book-origin.dto';
import { UpdateBookDto } from './dto/update-book.dto';
import { PinoLogger } from 'nestjs-pino';
import { QueryFailedError } from 'typeorm';
@UseGuards(JwtAccessGuard)
@Controller('books')
export class BooksController {
constructor(
private books: BooksService,
private logger: PinoLogger,
) { }
@Post('')
async CreateBook(
@Request() req,
@Body() body: BookSearchResultDto,
@Res({ passthrough: true }) response: Response,
) {
try {
return {
success: true,
data: await this.books.createBook(body),
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') {
// Book exists already.
response.statusCode = 400;
return {
success: false,
error_message: 'The book has already been added previously.',
}
}
}
this.logger.error({
class: BooksController.name,
method: this.CreateBook.name,
user: req.user,
msg: 'Failed to create book.',
error: err,
});
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong while adding the book.',
}
}
}
@Get('')
async GetBooksFromUser(
@Request() req,
) {
return {
success: true,
data: await this.books.findBookStatusesTrackedBy(req.user.userId),
};
}
@Put('')
async UpdateBook(
@Body() body: UpdateBookDto,
) {
const data = { ...body };
delete data['bookId'];
const result = await this.books.updateBook(body.bookId, data);
return {
success: result?.affected == 1,
};
}
@Put('origins')
async UpdateBookOrigin(
@Body() body: UpdateBookOriginDto,
) {
const data = { ...body };
delete data['bookOriginId'];
const result = await this.books.updateBookOrigin(body.bookOriginId, data);
return {
success: result?.affected == 1,
};
}
}

View File

@@ -1,12 +1,10 @@
import { Module } from '@nestjs/common';
import { BooksController } from './books.controller';
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 { ProvidersModule } from 'src/providers/providers.module';
import { SeriesModule } from 'src/series/series.module';
@Module({
@@ -18,9 +16,8 @@ import { SeriesModule } from 'src/series/series.module';
]),
SeriesModule,
HttpModule,
ProvidersModule,
],
controllers: [BooksController],
controllers: [],
exports: [
BooksService
],

View File

@@ -1,17 +1,17 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BookEntity } from './entities/book.entity';
import { In, InsertResult, Repository } from 'typeorm';
import { DeleteResult, In, InsertResult, Repository } from 'typeorm';
import { BookOriginEntity } from './entities/book-origin.entity';
import { BookStatusEntity } from './entities/book-status.entity';
import { UUID } from 'crypto';
import { PinoLogger } from 'nestjs-pino';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { BookOriginType } from 'src/shared/enums/book_origin_type';
import { CreateBookDto } from './dto/create-book.dto';
import { CreateBookOriginDto } from './dto/create-book-origin.dto';
import { CreateBookStatusDto as BookStatusDto } from './dto/book-status.dto';
import { SeriesService } from 'src/series/series.service';
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 {
@@ -22,82 +22,10 @@ export class BooksService {
private bookOriginRepository: Repository<BookOriginEntity>,
@InjectRepository(BookStatusEntity)
private bookStatusRepository: Repository<BookStatusEntity>,
private series: SeriesService,
private logger: PinoLogger,
) { }
async createBook(book: BookSearchResultDto) {
this.logger.debug({
class: BooksService.name,
method: this.createBook.name,
book: book,
msg: 'Saving book to database...',
});
if (book.providerSeriesId) {
await this.series.updateSeries({
providerSeriesId: book.providerSeriesId,
title: book.title,
provider: book.provider,
});
this.logger.debug({
class: BooksService.name,
method: this.createBook.name,
series: book.providerSeriesId,
msg: 'Series saved to database.',
});
}
const createBook: CreateBookDto = {
title: book.title,
desc: book.desc,
providerSeriesId: book.providerSeriesId,
providerBookId: book.providerBookId,
volume: book.volume,
provider: book.provider,
publishedAt: book.publishedAt,
};
const data = await this.createBookInternal(createBook);
const bookId = data.identifiers[0]['bookId'];
const tasks = [];
tasks.push(book.authors.map(author => this.addBookOrigin(bookId, BookOriginType.AUTHOR, author)));
tasks.push(book.categories.map(category => this.addBookOrigin(bookId, BookOriginType.CATEGORY, category)));
tasks.push(this.addBookOrigin(bookId, BookOriginType.LANGUAGE, book.language));
if (book.maturityRating) {
tasks.push(this.addBookOrigin(bookId, BookOriginType.MATURITY_RATING, book.maturityRating));
}
if (book.thumbnail) {
tasks.push(this.addBookOrigin(bookId, BookOriginType.PROVIDER_THUMBNAIL, book.thumbnail));
}
if (book.url) {
tasks.push(this.addBookOrigin(bookId, BookOriginType.PROVIDER_URL, book.url));
}
if ('ISBN_10' in book.industryIdentifiers) {
tasks.push(this.addBookOrigin(bookId, BookOriginType.ISBN_10, book.industryIdentifiers['ISBN_10']));
}
if ('ISBN_13' in book.industryIdentifiers) {
tasks.push(this.addBookOrigin(bookId, BookOriginType.ISBN_10, book.industryIdentifiers['ISBN_13']));
}
await Promise.all(tasks);
this.logger.info({
class: BooksService.name,
method: this.createBook.name,
book: book,
msg: 'Book saved to database.',
});
return bookId;
}
private async createBookInternal(book: CreateBookDto): Promise<InsertResult> {
async createBook(book: CreateBookDto): Promise<InsertResult> {
const entity = this.bookRepository.create(book);
return await this.bookRepository.createQueryBuilder()
.insert()
@@ -107,41 +35,68 @@ export class BooksService {
.execute();
}
async addBookOrigin(bookId: UUID, type: BookOriginType, value: string): Promise<InsertResult> {
return await this.bookOriginRepository.insert({
bookId,
type,
value,
});
async addBookOrigin(origin: CreateBookOriginDto): Promise<InsertResult> {
return await this.bookOriginRepository.insert(origin);
}
async findBooksByIds(bookIds: UUID[]) {
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 findBookStatusesTrackedBy(userId: UUID): Promise<BookStatusEntity[]> {
async findBooksFromSeries(series: SeriesDto): Promise<BookEntity[]> {
return await this.bookRepository.find({
where: {
providerSeriesId: series.providerSeriesId,
provider: series.provider,
}
});
}
async findBooks(): Promise<BookEntity[]> {
return await this.bookRepository.find();
}
async findActualBookStatusesTrackedBy(userId: UUID, series: SeriesDto): Promise<BookStatusEntity[]> {
return await this.bookStatusRepository.createQueryBuilder('s')
.select(['s.book_id', 's.user_id'])
.where('s.user_id = :id', { id: userId })
.innerJoin('s.book', 'b')
.addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider'])
.where('s.user_id = :id', { id: userId })
.andWhere('b.provider = :provider', { provider: series.provider })
.andWhere('b.providerSeriesId = :id', { id: series.providerSeriesId })
.addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider', 'b.providerSeriesId'])
.getMany();
}
async findSeriesTrackedBy(userId: UUID) {
return await this.bookStatusRepository.createQueryBuilder('s')
.where({
whereFactory: {
userId: userId
}
})
.innerJoin('s.book', 'b')
.addSelect(['b.provider', 'b.providerSeriesId'])
.distinctOn(['b.provider', 'b.providerSeriesId'])
async findBookStatusesTrackedBy(subscription: SeriesSubscriptionDto): Promise<any> {
return await this.bookRepository.createQueryBuilder('b')
.where('b.provider = :provider', { provider: subscription.provider })
.andWhere(`b.provider_series_id = :id`, { id: subscription.providerSeriesId })
.leftJoin('b.statuses', 's')
.where(`s.user_id = :id`, { id: subscription.userId })
.addSelect(['s.state'])
.getMany();
}
@@ -157,13 +112,12 @@ export class BooksService {
}, update);
}
async updateBookStatus(status: BookStatusDto) {
async updateBookStatus(status: CreateBookStatusDto) {
status.modifiedAt = new Date();
await this.bookStatusRepository.createQueryBuilder()
.insert()
.values(status)
.orUpdate(['user_id', 'book_id'], ['state', 'modified_at'], { skipUpdateIfNoValuesChanged: true })
.orUpdate(['state', 'modified_at'], ['user_id', 'book_id'])
.execute();
return await this.bookStatusRepository.upsert(status, ['book_id']);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
class GoogleSearchContext extends SearchContext {
import { SearchContext } from "./search.context";
export class GoogleSearchContext extends SearchContext {
constructor(searchQuery: string, params: { [key: string]: string }) {
super('google', searchQuery, params);
}
generateQueryParams() {
const filterParams = ['maxResults', 'startIndex'];
generateQueryParams(): string {
const filterParams = ['maxResults', 'startIndex', 'orderBy', 'langRestrict'];
const searchParams = ['intitle', 'inauthor', 'inpublisher', 'subject', 'isbn'];
const queryParams = filterParams
@@ -19,15 +21,123 @@ class GoogleSearchContext extends SearchContext {
...searchParams.map(p => this.params[p] ? p + ':"' + this.params[p] + '"' : ''),
].filter(p => p.length > 0).join('');
return queryParams + '&' + searchQueryParam;
return [queryParams, 'q=' + searchQueryParam].filter(q => q.length > 2).join('&');
}
next() {
const resultsPerPage = parseInt(this.params['maxResults']) ?? 10;
const index = parseInt(this.params['startIndex']) ?? 0;
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'] = (index + resultsPerPage).toString();
data['startIndex'] = Math.max(0, index + resultsPerPage * pageChange).toString();
return new GoogleSearchContext(this.search, data);
}

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,20 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, HttpException, Injectable } from '@nestjs/common';
import { BookSearchResultDto } from '../dto/book-search-result.dto';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom, map, timeout } from 'rxjs';
import { AxiosResponse } from 'axios';
import { catchError, EMPTY, firstValueFrom, map, timeout } from 'rxjs';
import { AxiosError, AxiosResponse } from 'axios';
import { GoogleSearchContext } from '../contexts/google.search.context';
import { PinoLogger } from 'nestjs-pino';
@Injectable()
export class GoogleService {
constructor(
private readonly http: HttpService,
private readonly logger: PinoLogger,
) { }
async searchRaw(searchQuery: string): Promise<BookSearchResultDto[]> {
const queryParams = 'printType=books&maxResults=10&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))&q=';
const queryParams = 'langRestrict=en&printType=books&maxResults=20&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))&q=';
return await firstValueFrom(
this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery)
@@ -23,6 +26,10 @@ export class GoogleService {
}
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();
@@ -30,7 +37,24 @@ export class GoogleService {
this.http.get('https://www.googleapis.com/books/v1/volumes?' + defaultQueryParams + '&' + customQueryParams)
.pipe(
timeout({ first: 5000 }),
map(this.transform),
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;
})
)
);
}
@@ -40,38 +64,56 @@ export class GoogleService {
return [];
}
return response.data.items.map(item => this.extract(item));
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: parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber),
volume: item.volumeInfo.seriesInfo?.bookDisplayNumber ? parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber, 10) : undefined,
publisher: item.volumeInfo.publisher,
authors: item.volumeInfo.authors,
categories: item.volumeInfo.categories,
categories: item.volumeInfo.categories ?? [],
mediaType: null,
maturityRating: item.volumeInfo.maturityRating,
industryIdentifiers: Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => ({ [i.type]: i.identifier }))),
industryIdentifiers: item.volumeInfo.industryIdentifiers ? Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => i.type == 'OTHER' ? { [i.identifier.split(':')[0]]: i.identifier.split(':')[1] } : { [i.type]: i.identifier })) : [],
publishedAt: new Date(item.volumeInfo.publishedDate),
language: item.volumeInfo.language,
thumbnail: item.volumeInfo.imageLinks?.thumbnail,
thumbnail: secure && item.volumeInfo.imageLinks?.thumbnail ? item.volumeInfo.imageLinks.thumbnail.replaceAll('http://', 'https://') : item.volumeInfo.imageLinks?.thumbnail,
url: item.volumeInfo.canonicalVolumeLink,
provider: 'google'
}
if (result.providerSeriesId) {
let regex = this.getRegexByPublisher(result.publisher);
};
const regex = this.getRegexByPublisher(result.publisher);
const match = result.title.match(regex);
if (match?.groups) {
result.title = match.groups['title'].trim();
if (!result.volume) {
result.volume = parseInt(match.groups['volume']);
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;
@@ -80,13 +122,17 @@ export class GoogleService {
private getRegexByPublisher(publisher: string): RegExp {
switch (publisher) {
case 'J-Novel Club':
return /(?<title>.+?):?\sVolume\s(?<volume>\d+)/i;
return /^(?<title>.+?):?(?:\s\((?<media_type>\w+)\))?(?:\sVolume\s(?<volume>\d+))?$/i;
case 'Yen On':
case 'Yen Press':
case 'Yen Press LLC':
return /(?<title>.+?),?\sVol\.\s(?<volume>\d+)\s\((?<media_type>[\w\s]+)\)/;
return /^(?:(?<title>.+?)(?:,?\sVol\.?\s(?<volume>\d+))(?:\s\((?<media_type>[\w\s]+)\))?)$/i;
case 'Hanashi Media':
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\),?\sVol\.\s(?<volume>\d+)$/i
case 'Regin\'s Chronicles':
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\)(?<subtitle>\:\s.+?)?$/i
default:
return /(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)/;
return /^(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)$/i;
}
}
}

View File

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

View File

@@ -1,6 +1,8 @@
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 {
@@ -10,7 +12,7 @@ export class ProvidersService {
generateSearchContext(providerName: string, searchQuery: string): SearchContext | null {
let params: { [key: string]: string } = {};
if (providerName == 'google') {
if (providerName.toLowerCase() == 'google') {
return new GoogleSearchContext(searchQuery, params);
}
return null;
@@ -28,7 +30,7 @@ export class ProvidersService {
async search(context: SearchContext): Promise<BookSearchResultDto[]> {
switch (context.provider.toLowerCase()) {
case 'google':
return await this.google.search(context);
return await this.google.search(context as GoogleSearchContext);
default:
throw Error('Invalid provider name.');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { UUID } from 'crypto';
import { BookEntity } from 'src/books/entities/book.entity';
import { Column, Entity, OneToMany, PrimaryColumn, Unique } from 'typeorm';
import { SeriesSubscriptionEntity } from './series-subscription.entity';
@Entity("series")
@Unique(['provider', 'providerSeriesId'])
@@ -14,6 +15,9 @@ export class SeriesEntity {
@Column({ name: 'series_title', type: 'text', nullable: false })
title: string;
@Column({ name: 'media_type', type: 'text', nullable: true })
mediaType: string;
@Column({ name: 'provider', type: 'text', nullable: false })
provider: string;
@@ -22,4 +26,7 @@ export class SeriesEntity {
@OneToMany(type => BookEntity, book => [book.provider, book.providerSeriesId])
volumes: BookEntity[];
@OneToMany(type => SeriesSubscriptionEntity, subscription => [subscription.provider, subscription.providerSeriesId])
subscriptions: SeriesSubscriptionEntity[];
}

View File

@@ -4,11 +4,13 @@ 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,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
frontend/angular-seshat/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

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

View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

View File

@@ -0,0 +1,27 @@
# AngularSeshat
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.5.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -0,0 +1,136 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"angular-seshat": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/angular-seshat",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/custom-theme.scss",
"./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
"src/styles.css"
],
"scripts": [],
"server": "src/main.server.ts",
"prerender": true,
"ssr": {
"entry": "server.ts"
}
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kB",
"maximumError": "4kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "angular-seshat:build:production"
},
"development": {
"buildTarget": "angular-seshat:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
}
}
}
},
"library": {
"projectType": "library",
"root": "projects/library",
"sourceRoot": "projects/library/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/library/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/library/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/library/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/library/tsconfig.spec.json",
"polyfills": [
"zone.js",
"zone.js/testing"
]
}
}
}
}
}
}

14418
frontend/angular-seshat/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"name": "angular-seshat",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr:angular-seshat": "node dist/angular-seshat/server/server.mjs"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.0.0",
"@angular/cdk": "^18.2.14",
"@angular/common": "^18.0.0",
"@angular/compiler": "^18.0.0",
"@angular/core": "^18.0.0",
"@angular/forms": "^18.0.0",
"@angular/material": "^18.2.14",
"@angular/platform-browser": "^18.0.0",
"@angular/platform-browser-dynamic": "^18.0.0",
"@angular/platform-server": "^18.0.0",
"@angular/router": "^18.0.0",
"@angular/ssr": "^18.0.5",
"express": "^4.18.2",
"ngx-cookie-service": "^18.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.0.5",
"@angular/cli": "^18.0.5",
"@angular/compiler-cli": "^18.0.0",
"@types/express": "^4.17.17",
"@types/jasmine": "~5.1.0",
"@types/node": "^18.18.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"ng-packagr": "^18.2.0",
"typescript": "~5.4.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M80-140v-320h320v320H80Zm80-80h160v-160H160v160Zm60-340 220-360 220 360H220Zm142-80h156l-78-126-78 126ZM863-42 757-148q-21 14-45.5 21t-51.5 7q-75 0-127.5-52.5T480-300q0-75 52.5-127.5T660-480q75 0 127.5 52.5T840-300q0 26-7 50.5T813-204L919-98l-56 56ZM660-200q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29ZM320-380Zm120-260Z"/></svg>

After

Width:  |  Height:  |  Size: 472 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>

After

Width:  |  Height:  |  Size: 222 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 537 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>

After

Width:  |  Height:  |  Size: 376 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="m40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z"/></svg>

After

Width:  |  Height:  |  Size: 314 B

View File

@@ -0,0 +1,57 @@
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
const commonEngine = new CommonEngine();
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get('**', express.static(browserDistFolder, {
maxAge: '1y',
index: 'index.html',
}));
// All regular routes use the Angular engine
server.get('**', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then((html) => res.send(html))
.catch((err) => next(err));
});
return server;
}
function run(): void {
const port = process.env['PORT'] || 4000;
// Start up the Node server
const server = app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
run();

View File

@@ -0,0 +1,10 @@
.loading-container {
display: flex;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
}

View File

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

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'angular-seshat' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('angular-seshat');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, angular-seshat');
});
});

View File

@@ -0,0 +1,53 @@
import { Component, inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AuthService } from './services/auth/auth.service';
import { ConfigService } from './services/config.service';
import { isPlatformBrowser } from '@angular/common';
import { LoadingService } from './services/loading.service';
import { Subscription } from 'rxjs';
import { RedirectionService } from './services/redirection.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent implements OnInit, OnDestroy {
private readonly _auth = inject(AuthService);
private readonly _config = inject(ConfigService);
private readonly _loading = inject(LoadingService);
private readonly _platformId = inject(PLATFORM_ID);
private readonly _redirect = inject(RedirectionService);
private readonly _subscriptions: Subscription[] = [];
loading: boolean = false;
ngOnInit() {
if (!isPlatformBrowser(this._platformId)) {
return;
}
this.listenToLoading();
this._config.fetch();
this._auth.update();
this._loading.listenUntilReady()
.subscribe(async () => {
this._redirect.redirect(null);
});
}
ngOnDestroy(): void {
this._subscriptions.forEach(s => s.unsubscribe());
}
listenToLoading(): void {
this._subscriptions.push(
this._loading.listen()
.subscribe((loading) => this.loading = loading)
);
}
}

View File

@@ -0,0 +1,11 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering()
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

View File

@@ -0,0 +1,23 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { provideHttpClient, withInterceptorsFromDi, withFetch, HTTP_INTERCEPTORS } from '@angular/common/http';
import { LoadingInterceptor } from './shared/interceptors/loading.interceptor';
import { TokenValidationInterceptor } from './shared/interceptors/token-validation.interceptor';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideClientHydration(),
provideHttpClient(
withInterceptorsFromDi(),
withFetch()
),
LoadingInterceptor,
TokenValidationInterceptor,
provideAnimationsAsync(),
]
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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