Compare commits

..

30 Commits

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

View File

@@ -11,6 +11,8 @@ DROP TABLE IF EXISTS book_origins;
DROP TABLE IF EXISTS refresh_tokens; DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS series_subscriptions;
DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS books; DROP TABLE IF EXISTS books;
@@ -23,6 +25,7 @@ CREATE TABLE
-- 3rd party id for this series. -- 3rd party id for this series.
provider_series_id text, provider_series_id text,
series_title text NOT NULL, series_title text NOT NULL,
media_type text,
-- 3rd party used to fetch the data for this series. -- 3rd party used to fetch the data for this series.
provider varchar(12) NOT NULL, provider varchar(12) NOT NULL,
added_at timestamp default NULL, added_at timestamp default NULL,
@@ -51,9 +54,8 @@ CREATE TABLE
published_at timestamp default NULL, published_at timestamp default NULL,
added_at timestamp default NULL, added_at timestamp default NULL,
PRIMARY KEY (book_id), PRIMARY KEY (book_id),
-- FOREIGN KEY (series_id) REFERENCES series (series_id),
FOREIGN KEY (provider, provider_series_id) REFERENCES series (provider, provider_series_id) ON DELETE CASCADE, 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 ALTER TABLE books
@@ -61,7 +63,7 @@ ALTER COLUMN added_at
SET DEFAULT now (); SET DEFAULT now ();
-- CREATE INDEX books_series_id_idx ON books (series_id); -- 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_isbn_idx ON books USING HASH (isbn);
CREATE INDEX books_book_title_idx ON books (book_title); CREATE INDEX books_book_title_idx ON books (book_title);
@@ -111,7 +113,7 @@ CREATE TABLE
book_statuses ( book_statuses (
user_id uuid, user_id uuid,
book_id uuid, book_id uuid,
state varchar(12), state smallint,
added_at timestamp default NULL, added_at timestamp default NULL,
modified_at timestamp default NULL, modified_at timestamp default NULL,
PRIMARY KEY (user_id, book_id), PRIMARY KEY (user_id, book_id),
@@ -128,7 +130,7 @@ CREATE INDEX book_statuses_user_id_login_idx ON users (user_id);
CREATE TABLE CREATE TABLE
series_subscriptions ( series_subscriptions (
user_id uuid, user_id uuid,
provider text, provider varchar(12) NOT NULL,
provider_series_id text, provider_series_id text,
added_at timestamp default NULL, added_at timestamp default NULL,
PRIMARY KEY (user_id, provider, provider_series_id), 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", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.0", "@nestjs/axios": "^4.0.0",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.0", "@nestjs/config": "^4.0.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
@@ -19,14 +20,17 @@
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"bullmq": "^5.41.7",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"moment": "^2.30.1", "moment": "^2.30.1",
"nestjs-pino": "^4.3.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"pino-http": "^10.4.0",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
@@ -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": { "node_modules/@angular-devkit/schematics": {
"version": "17.3.11", "version": "17.3.11",
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz", "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz",
@@ -213,6 +227,16 @@
"node": ">=0.12.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.26.2", "version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -937,6 +961,12 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "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": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1574,6 +1604,84 @@
"node": ">=8" "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": { "node_modules/@nestjs/axios": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.0.tgz",
@@ -1585,6 +1693,34 @@
"rxjs": "^7.0.0" "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": { "node_modules/@nestjs/cli": {
"version": "10.4.9", "version": "10.4.9",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
@@ -2424,17 +2560,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.24.1", "version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz",
"integrity": "sha512-ll1StnKtBigWIGqvYDVuDmXJHVH4zLVot1yQ4fJtLpL7qacwkxJc1T0bptqw+miBQ/QfUbhl1TcQ4accW5KUyA==", "integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.24.1", "@typescript-eslint/scope-manager": "8.25.0",
"@typescript-eslint/type-utils": "8.24.1", "@typescript-eslint/type-utils": "8.25.0",
"@typescript-eslint/utils": "8.24.1", "@typescript-eslint/utils": "8.25.0",
"@typescript-eslint/visitor-keys": "8.24.1", "@typescript-eslint/visitor-keys": "8.25.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^5.3.1", "ignore": "^5.3.1",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@@ -2454,16 +2590,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.24.1", "version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz",
"integrity": "sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==", "integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.24.1", "@typescript-eslint/scope-manager": "8.25.0",
"@typescript-eslint/types": "8.24.1", "@typescript-eslint/types": "8.25.0",
"@typescript-eslint/typescript-estree": "8.24.1", "@typescript-eslint/typescript-estree": "8.25.0",
"@typescript-eslint/visitor-keys": "8.24.1", "@typescript-eslint/visitor-keys": "8.25.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@@ -2479,14 +2615,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.24.1", "version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz",
"integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==", "integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.24.1", "@typescript-eslint/types": "8.25.0",
"@typescript-eslint/visitor-keys": "8.24.1" "@typescript-eslint/visitor-keys": "8.25.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2497,14 +2633,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.24.1", "version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz",
"integrity": "sha512-/Do9fmNgCsQ+K4rCz0STI7lYB4phTtEXqqCAs3gZW0pnK7lWNkvWd5iW545GSmApm4AzmQXmSqXPO565B4WVrw==", "integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.24.1", "@typescript-eslint/typescript-estree": "8.25.0",
"@typescript-eslint/utils": "8.24.1", "@typescript-eslint/utils": "8.25.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.0.1" "ts-api-utils": "^2.0.1"
}, },
@@ -2521,9 +2657,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.24.1", "version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz",
"integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==", "integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2535,14 +2671,14 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.24.1", "version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz",
"integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==", "integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.24.1", "@typescript-eslint/types": "8.25.0",
"@typescript-eslint/visitor-keys": "8.24.1", "@typescript-eslint/visitor-keys": "8.25.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@@ -2562,16 +2698,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.24.1", "version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz",
"integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==", "integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.24.1", "@typescript-eslint/scope-manager": "8.25.0",
"@typescript-eslint/types": "8.24.1", "@typescript-eslint/types": "8.25.0",
"@typescript-eslint/typescript-estree": "8.24.1" "@typescript-eslint/typescript-estree": "8.25.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2586,13 +2722,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.24.1", "version": "8.25.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz",
"integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==", "integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.24.1", "@typescript-eslint/types": "8.25.0",
"eslint-visitor-keys": "^4.2.0" "eslint-visitor-keys": "^4.2.0"
}, },
"engines": { "engines": {
@@ -3068,6 +3204,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "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": { "node_modules/axios": {
"version": "1.7.9", "version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
@@ -3425,6 +3570,21 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT" "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": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -3811,6 +3971,15 @@
"node": ">=0.8" "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": { "node_modules/co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -4062,6 +4231,18 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4171,6 +4352,15 @@
"node": ">=0.4.0" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -4190,6 +4380,16 @@
"npm": "1.2.8000 || >= 1.4.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": { "node_modules/detect-newline": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -4323,9 +4523,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.102", "version": "1.5.104",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.102.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.104.tgz",
"integrity": "sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==", "integrity": "sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -4943,6 +5143,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fast-safe-stringify": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -5137,12 +5346,12 @@
} }
}, },
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.0", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"cross-spawn": "^7.0.0", "cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1" "signal-exit": "^4.0.1"
}, },
"engines": { "engines": {
@@ -5326,17 +5535,17 @@
} }
}, },
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.2.7", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0", "es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2", "function-bind": "^1.1.2",
"get-proto": "^1.0.0", "get-proto": "^1.0.1",
"gopd": "^1.2.0", "gopd": "^1.2.0",
"has-symbols": "^1.1.0", "has-symbols": "^1.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.2",
@@ -5724,6 +5933,30 @@
"node": ">=12.0.0" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -6962,12 +7195,24 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT" "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": { "node_modules/lodash.isboolean": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -7045,6 +7290,15 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.30.8", "version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
@@ -7287,6 +7541,37 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/multer": {
"version": "1.4.4-lts.1", "version": "1.4.4-lts.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz",
@@ -7346,17 +7631,32 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/node-abort-controller": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-addon-api": { "node_modules/node-addon-api": {
"version": "8.3.0", "version": "8.3.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.1.tgz",
"integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", "integrity": "sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^18 || ^20 || >= 21" "node": "^18 || ^20 || >= 21"
@@ -7403,6 +7703,21 @@
"node-gyp-build-test": "build-test.js" "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": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -7461,6 +7776,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -7896,6 +8220,55 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/pirates": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
@@ -8035,9 +8408,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.5.1", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz",
"integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -8097,6 +8470,22 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT" "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": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -8193,6 +8582,12 @@
], ],
"license": "MIT" "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": { "node_modules/randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -8281,6 +8676,36 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -8510,9 +8935,9 @@
} }
}, },
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
@@ -8538,6 +8963,15 @@
], ],
"license": "MIT" "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": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -8841,6 +9275,15 @@
"node": ">=8" "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": { "node_modules/source-map": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@@ -8911,6 +9354,12 @@
"node": ">=8" "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": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -9355,6 +9804,15 @@
"node": ">=0.8" "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": { "node_modules/through": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@@ -9434,9 +9892,9 @@
} }
}, },
"node_modules/ts-jest": { "node_modules/ts-jest": {
"version": "29.2.5", "version": "29.2.6",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.6.tgz",
"integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", "integrity": "sha512-yTNZVZqc8lSixm+QGVFcPe6+yj7+TWZwIesuOWvfcn4B9bz5x4NDzVCQQjOs7Hfouu36aEqfEbo9Qpo+gq8dDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9447,7 +9905,7 @@
"json5": "^2.2.3", "json5": "^2.2.3",
"lodash.memoize": "^4.1.2", "lodash.memoize": "^4.1.2",
"make-error": "^1.3.6", "make-error": "^1.3.6",
"semver": "^7.6.3", "semver": "^7.7.1",
"yargs-parser": "^21.1.1" "yargs-parser": "^21.1.1"
}, },
"bin": { "bin": {

View File

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

View File

@@ -11,8 +11,13 @@ import { UsersModule } from './users/users.module';
import { UserEntity } from './users/entities/users.entity'; import { UserEntity } from './users/entities/users.entity';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { serialize_token, serialize_user_short, serialize_user_long, serialize_res, serialize_req } from './logging.serializers'; import { serialize_token, serialize_user_short, serialize_user_long, serialize_res, serialize_req, serialize_job } from './logging.serializers';
import { BooksModule } from './books/books.module';
import { ProvidersModule } from './providers/providers.module'; import { ProvidersModule } from './providers/providers.module';
import { SeriesModule } from './series/series.module';
import { LibraryModule } from './library/library.module';
import { BullModule } from '@nestjs/bullmq';
import { AssetModule } from './asset/asset.module';
@Module({ @Module({
imports: [ imports: [
@@ -21,6 +26,17 @@ import { ProvidersModule } from './providers/providers.module';
imports: [ConfigModule], imports: [ConfigModule],
useClass: DatabaseOptions useClass: DatabaseOptions
}), }),
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: {
host: config.get('REDIS_HOST') ?? 'localhost',
port: config.get('REDIS_PORT') ?? 6379,
password: config.get('REDIS_PASSWORD'),
}
})
}),
TypeOrmModule.forFeature([UserEntity]), TypeOrmModule.forFeature([UserEntity]),
UsersModule, UsersModule,
AuthModule, AuthModule,
@@ -36,12 +52,14 @@ import { ProvidersModule } from './providers/providers.module';
user: value => serialize_user_long(value), user: value => serialize_user_long(value),
access_token: value => serialize_token(value), access_token: value => serialize_token(value),
refresh_token: value => serialize_token(value), refresh_token: value => serialize_token(value),
job: value => serialize_job(value),
req: value => serialize_req(value), req: value => serialize_req(value),
res: value => serialize_res(value), res: value => serialize_res(value),
} : { } : {
user: value => serialize_user_short(value), user: value => serialize_user_short(value),
access_token: value => serialize_token(value), access_token: value => serialize_token(value),
refresh_token: value => serialize_token(value), refresh_token: value => serialize_token(value),
job: value => serialize_job(value),
req: value => serialize_req(value), req: value => serialize_req(value),
res: value => serialize_res(value), res: value => serialize_res(value),
}, },
@@ -54,7 +72,12 @@ import { ProvidersModule } from './providers/providers.module';
} }
} }
}), }),
ProvidersModule BooksModule,
ProvidersModule,
SeriesModule,
LibraryModule,
ConfigModule,
AssetModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService, UsersService], providers: [AppService, UsersService],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
abstract class SearchContext { export abstract class SearchContext {
provider: string; provider: string;
search: string; search: string;
params: { [key: string]: string }; params: { [key: string]: string };
@@ -9,6 +9,7 @@ abstract class SearchContext {
this.params = params; this.params = params;
} }
abstract generateQueryParams(); abstract generateQueryParams(): string;
abstract next(); 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() @IsNotEmpty()
language: string; language: string;
@IsString()
@IsOptional()
mediaType: string | null;
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
categories: string[]; categories: string[];

View File

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

View File

@@ -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 { ProvidersService } from './providers.service';
import { BookSearchInputDto } from './dto/book-search-input.dto';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard'; import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { SimplifiedSearchContext } from './contexts/simplified-search-context';
@Controller('providers') @Controller('providers')
export class ProvidersController { export class ProvidersController {
@@ -12,8 +12,10 @@ export class ProvidersController {
@UseGuards(JwtAccessGuard) @UseGuards(JwtAccessGuard)
@Get('search') @Get('search')
async 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

@@ -4,7 +4,7 @@ import { ProvidersService } from './providers.service';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { ProvidersController } from './providers.controller'; import { ProvidersController } from './providers.controller';
import { BooksService } from 'src/books/books/books.service'; import { BooksService } from 'src/books/books.service';
@Module({ @Module({
imports: [ imports: [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { UUID } from "crypto"; import { UUID } from "crypto";
import { BookStatusEntity } from 'src/books/books/entities/book-status.entity'; import { BookStatusEntity } from 'src/books/entities/book-status.entity';
import { BigIntTransformer } from 'src/shared/transformers/bigint'; import { BigIntTransformer } from 'src/shared/transformers/bigint';
import { StringToLowerCaseTransformer } from 'src/shared/transformers/string'; import { StringToLowerCaseTransformer } from 'src/shared/transformers/string';
import { BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; import { BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 472 B

View File

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

After

Width:  |  Height:  |  Size: 222 B

View File

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

After

Width:  |  Height:  |  Size: 537 B

View File

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

After

Width:  |  Height:  |  Size: 376 B

View File

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

After

Width:  |  Height:  |  Size: 314 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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>

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