From a7cf7c8987b8847984629293d8eb27908f3de3dd Mon Sep 17 00:00:00 2001 From: vnugent Date: Mon, 8 Apr 2024 22:04:04 -0400 Subject: Squashed commit of the following: commit 44803e06d1aa45496c04127930aa8897272d42f6 Author: vnugent Date: Mon Apr 8 21:41:38 2024 -0400 fix: dangling/expired session security check and cookie cleanup commit 1082bd146549a1aff47877bcd28e6be1ce0ef5e9 Author: vnugent Date: Sat Mar 30 22:20:29 2024 -0400 feat(app): Add AppData client plugin and browser library updated commit ec9b42f4cacbeae8a0b4d96e48bd9e522b3a9145 Merge: 2a11454 27b487b Author: vnugent Date: Sun Mar 24 21:16:05 2024 -0400 Merge branch 'master' into develop commit 2a114541a3bfddae887adaa98c1ed326b125d511 Author: vnugent Date: Sun Mar 24 20:53:38 2024 -0400 refactor: pull apart session authorization for future dev commit f8aea6453ddb2d56c1ce2ecb6a9e67d1af523c2e Author: vnugent Date: Thu Mar 21 14:33:21 2024 -0400 feat: Add optional svg base64 icons for social OAuth2 connections commit cc29bed99dc9e151315cce75e50d55dca306b532 Author: vnugent Date: Sun Mar 10 21:58:27 2024 -0400 source tree project location updated --- lib/vnlib.browser/package-lock.json | 176 +++++++-------- lib/vnlib.browser/package.json | 4 +- lib/vnlib.browser/src/app-data/index.ts | 147 ++++++++++++ lib/vnlib.browser/src/index.ts | 3 + lib/vnlib.browser/src/social/index.ts | 19 ++ plugins.essentials.build.sln | 6 + .../README.md | 18 ++ .../build.readme.md | 0 .../src/AppDataEntry.cs | 54 +++++ .../src/CacheStore.cs | 249 +++++++++++++++++++++ .../src/Endpoints/WebEndpoint.cs | 203 +++++++++++++++++ .../src/Model/HttpExtensions.cs | 75 +++++++ .../src/Model/IAppDataStore.cs | 38 ++++ .../src/Model/RecordDataCacheEntry.cs | 38 ++++ .../src/Model/RecordOpFlags.cs | 35 +++ .../src/Model/UserRecordData.cs | 28 +++ .../src/Stores/PersistentStorageManager.cs | 75 +++++++ .../src/Stores/Sql/DataRecord.cs | 59 +++++ .../src/Stores/Sql/SqlBackingStore.cs | 144 ++++++++++++ .../src/Stores/Sql/UserRecordDbContext.cs | 59 +++++ ...NLib.Plugins.Essentials.Accounts.AppData.csproj | 67 ++++++ .../src/SecurityProvider/AccountSecProvider.cs | 53 +++-- .../src/SecurityProvider/ClientWebAuthManager.cs | 13 +- 23 files changed, 1446 insertions(+), 117 deletions(-) create mode 100644 lib/vnlib.browser/src/app-data/index.ts create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/build.readme.md create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/AppDataEntry.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/CacheStore.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Endpoints/WebEndpoint.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/HttpExtensions.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/IAppDataStore.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordDataCacheEntry.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordOpFlags.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/UserRecordData.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/PersistentStorageManager.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/DataRecord.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/SqlBackingStore.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/UserRecordDbContext.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj diff --git a/lib/vnlib.browser/package-lock.json b/lib/vnlib.browser/package-lock.json index acb1b62..42a89df 100644 --- a/lib/vnlib.browser/package-lock.json +++ b/lib/vnlib.browser/package-lock.json @@ -12,7 +12,7 @@ "@babel/types": "^7.x", "@types/lodash-es": "^4.14.x", "@types/node": "^20.5.1", - "@typescript-eslint/eslint-plugin": "^6.x.x" + "@typescript-eslint/eslint-plugin": "^7.x.x" }, "peerDependencies": { "@vueuse/core": "^10.x", @@ -34,9 +34,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", + "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", "dev": true, "engines": { "node": ">=6.9.0" @@ -52,9 +52,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", - "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", + "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", "peer": true, "bin": { "parser": "bin/babel-parser.js" @@ -259,9 +259,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.14.202", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", - "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", "dev": true }, "node_modules/@types/lodash-es": { @@ -274,9 +274,9 @@ } }, "node_modules/@types/node": { - "version": "20.11.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz", - "integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==", + "version": "20.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.2.tgz", + "integrity": "sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -295,16 +295,16 @@ "peer": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz", + "integrity": "sha512-yHMQ/oFaM7HZdVrVm/M2WHaNPgyuJH4WelkSVEWSSsir34kxW2kDJCxlXRhhGWEsMN0WAW/vLpKfKVcm8k+MPw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "7.4.0", + "@typescript-eslint/type-utils": "7.4.0", + "@typescript-eslint/utils": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -313,15 +313,15 @@ "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -330,27 +330,27 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.4.0.tgz", + "integrity": "sha512-ZvKHxHLusweEUVwrGRXXUVzFgnWhigo4JurEj0dGF1tbcGh6buL+ejDdjxOQxv6ytcY1uhun1p2sm8iWStlgLQ==", "dev": true, "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "7.4.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/typescript-estree": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -359,16 +359,16 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz", + "integrity": "sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -376,25 +376,25 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.4.0.tgz", + "integrity": "sha512-247ETeHgr9WTRMqHbbQdzwzhuyaJ8dPTuyuUEMANqzMRB1rj/9qFIuIXK7l0FX9i9FXbHeBQl/4uz6mYuCE7Aw==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/typescript-estree": "7.4.0", + "@typescript-eslint/utils": "7.4.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -403,12 +403,12 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.4.0.tgz", + "integrity": "sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==", "dev": true, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -416,13 +416,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz", + "integrity": "sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -431,7 +431,7 @@ "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -444,41 +444,41 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.4.0.tgz", + "integrity": "sha512-NQt9QLM4Tt8qrlBVY9lkMYzfYtNz8/6qwZg8pI3cMGlPnj6mOpRxxAm7BMJN9K0AiY+1BwJ5lVC650YJqYOuNg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/scope-manager": "7.4.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/typescript-estree": "7.4.0", "semver": "^7.5.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz", + "integrity": "sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/types": "7.4.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -761,12 +761,12 @@ "peer": true }, "node_modules/axios": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", - "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", "peer": true, "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -1250,9 +1250,9 @@ "peer": true }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -1805,9 +1805,9 @@ } }, "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", "funding": [ { "type": "opencollective", @@ -1826,7 +1826,7 @@ "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -1976,9 +1976,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "peer": true, "engines": { "node": ">=0.10.0" @@ -2048,9 +2048,9 @@ } }, "node_modules/ts-api-utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", - "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", "dev": true, "engines": { "node": ">=16" @@ -2084,9 +2084,9 @@ } }, "node_modules/typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", - "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", + "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", "devOptional": true, "peer": true, "bin": { diff --git a/lib/vnlib.browser/package.json b/lib/vnlib.browser/package.json index 056b187..795b36a 100644 --- a/lib/vnlib.browser/package.json +++ b/lib/vnlib.browser/package.json @@ -4,7 +4,7 @@ "author": "Vaughn Nugent", "description": "Client JavaScript helper library for vuejs3 web-apps for connecting with Essentials.Accounts plugin and vuejs helpers.", "repository":"https://github.com/VnUgE/Plugins.Essentials/tree/master/lib/vnlib.browser", - "copyright":"Copyright \u00A9 2023 Vaughn Nugent", + "copyright":"Copyright \u00A9 2024 Vaughn Nugent", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -20,7 +20,7 @@ "@babel/types": "^7.x", "@types/lodash-es": "^4.14.x", "@types/node": "^20.5.1", - "@typescript-eslint/eslint-plugin": "^6.x.x" + "@typescript-eslint/eslint-plugin": "^7.x.x" }, "peerDependencies": { diff --git a/lib/vnlib.browser/src/app-data/index.ts b/lib/vnlib.browser/src/app-data/index.ts new file mode 100644 index 0000000..40cf054 --- /dev/null +++ b/lib/vnlib.browser/src/app-data/index.ts @@ -0,0 +1,147 @@ + +// Copyright (c) 2024 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { MaybeRef, get, type StorageLikeAsync } from '@vueuse/core' +import { useAxios } from '../axios' +import { defaultTo, first } from 'lodash-es' +import type { Axios } from 'axios' + +export interface UserAppDataApi { + /** + * Gets data from the app-data server + * @param scope The scope of the data to get from the store + * @param noCache A value indicating if the cache should be bypassed + * @returns A promise that resolves to the data or undefined if the data does not exist + */ + get(scope: string, noCache: boolean): Promise + /** + * Sets arbitrary data in the app-data server + * @param scope The scope of the data to set in the store + * @param data The data to set in the store + * @param wait A value indicating if the request should wait for the data to be written to the store + */ + set(scope: string, data: T, wait: boolean): Promise + /** + * Completely removes data from the app-data server + * @param scope The scope of the data to remove from the store + */ + remove(scope: string): Promise +} + +export interface ScopedUserAppDataApi { + /** + * Gets data from the app-data server for the configured scope + * @param noCache A value indicating if the cache should be bypassed + * @returns A promise that resolves to the data or undefined if the data does not exist + */ + get(noCache: boolean): Promise + /** + * Sets arbitrary data in the app-data server for the configured scope + * @param data The data to set in the store + * @param wait A value indicating if the request should wait for the data to be written to the store + * @returns A promise that resolves when the data has been written to the store + */ + set(data: T, wait: boolean): Promise + /** + * Completely removes data from the app-data server for the configured scope + * @returns A promise that resolves when the data has been removed from the store + */ + remove(): Promise +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Creates an AppData API for the given endpoint + * @param endpoint The endpoint to use + * @param axios The optional axios instance to use for requests + * @returns The AppData API + */ +export const useAppDataApi = (endpoint: MaybeRef, axios?: Axios): UserAppDataApi => { + + axios = defaultTo(axios, useAxios(null)); + + const getUrl = (scope: string, noCache: boolean, flush: boolean) => { + const fl = flush ? '&flush=true' : '' + const nc = noCache ? '&noCache=true' : '' + return `${get(endpoint)}?scope=${scope}${nc}${fl}` + } + + return { + get: async (scope: string, noCache: boolean): Promise => { + try { + const { data } = await axios!.get(getUrl(scope, noCache, false)) + return data; + } + catch (err: any) { + //Handle 404 errors as null + if ('response' in err && err.response.status === 404) { + return undefined; + } + } + }, + + set: async (scope: string, data: T, wait: boolean) => { + return axios!.put(getUrl(scope, false, wait), data) + }, + + remove: async (scope: string) => { + return axios!.delete(getUrl(scope, false, false)) + } + } +} + +/** + * Creates an AppData API that uses at constant scope for all requests + * @param endpoint The app-data endpoint to use + * @param scope The data request scope + * @param axios The optional axios instance to use for requests + */ +export const useScopedAppDataApi = (endpoint: MaybeRef, scope: MaybeRef, axios?: Axios): ScopedUserAppDataApi => { + const api = useAppDataApi(endpoint, axios); + + return { + get: (noCache: boolean) => api.get(get(scope), noCache), + set: (data: T, wait: boolean) => api.set(get(scope), data, wait), + remove: () => api.remove(get(scope)) + } +} + +/** + * Creates a StorageLikeAsync object that uses the given UserAppDataApi + * @param api The UserAppDataApi instance to use + * @returns The StorageLikeAsync object + */ +export const useAppDataAsyncStorage = (api: UserAppDataApi): StorageLikeAsync => { + return{ + getItem: async (key: string) => { + const result = await api.get(key, false) + return first(result) || null + }, + setItem: (key: string, value: string) => { + //NOTE: An array is used to force axios to serialize the + //value and send the data to the server a file + return api.set(key, [value], false) + }, + removeItem: async (key: string) => { + return api.remove(key) + } + } +} \ No newline at end of file diff --git a/lib/vnlib.browser/src/index.ts b/lib/vnlib.browser/src/index.ts index 47cd9e9..de0f651 100644 --- a/lib/vnlib.browser/src/index.ts +++ b/lib/vnlib.browser/src/index.ts @@ -37,6 +37,9 @@ export * from './social' //Forward session public exports export * from './session' +//App-data +export * from './app-data' + //Axios exports export { useAxios } from './axios' diff --git a/lib/vnlib.browser/src/social/index.ts b/lib/vnlib.browser/src/social/index.ts index 7d80687..cbcd0f9 100644 --- a/lib/vnlib.browser/src/social/index.ts +++ b/lib/vnlib.browser/src/social/index.ts @@ -1,3 +1,22 @@ +// Copyright (c) 2023 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + import { find, first, isArray, isEqual, map } from "lodash-es"; import { Mutable, get } from "@vueuse/core"; import Cookies from "universal-cookie"; diff --git a/plugins.essentials.build.sln b/plugins.essentials.build.sln index 0bf061c..3211b22 100644 --- a/plugins.essentials.build.sln +++ b/plugins.essentials.build.sln @@ -31,6 +31,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VNLib.Plugins.Essentials.Au EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VNLib.Plugins.Essentials.Auth.Auth0", "plugins\providers\VNLib.Plugins.Essentials.Auth.Auth0\src\VNLib.Plugins.Essentials.Auth.Auth0.csproj", "{B192FDBC-D22A-49CF-85C7-F421E3FA1B25}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VNLib.Plugins.Essentials.Accounts.AppData", "plugins\VNLib.Plugins.Essentials.Accounts.AppData\src\VNLib.Plugins.Essentials.Accounts.AppData.csproj", "{CCA18B6A-491F-424A-9104-6D399D9CB775}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -65,6 +67,10 @@ Global {B192FDBC-D22A-49CF-85C7-F421E3FA1B25}.Debug|Any CPU.Build.0 = Debug|Any CPU {B192FDBC-D22A-49CF-85C7-F421E3FA1B25}.Release|Any CPU.ActiveCfg = Release|Any CPU {B192FDBC-D22A-49CF-85C7-F421E3FA1B25}.Release|Any CPU.Build.0 = Release|Any CPU + {CCA18B6A-491F-424A-9104-6D399D9CB775}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CCA18B6A-491F-424A-9104-6D399D9CB775}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CCA18B6A-491F-424A-9104-6D399D9CB775}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CCA18B6A-491F-424A-9104-6D399D9CB775}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md new file mode 100644 index 0000000..0702226 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md @@ -0,0 +1,18 @@ +# VNLib.Plugins.Essentials.Accounts.AppData +*An Essentials plugin that provides endpoints for web-application synchronized storage such as user preferences.* + +> [!WARNING] +> This plugin is still in early development and is not yet ready for production use. + +## Builds +Debug build w/ symbols & xml docs, release builds, NuGet packages, and individually packaged source code are available on my website (link below). + +## Docs and Guides +Documentation, specifications, and setup guides are available on my website. + +[Docs and Articles](https://www.vaughnnugent.com/resources/software/articles?tags=docs,_VNLib.Plugins.Essentials.Accounts.AppData) +[Builds and Source](https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials) +[Nuget Feeds](https://www.vaughnnugent.com/resources/software/modules) + +## License +Source files in for this project are licensed to you under the GNU Affero General Public License (or any later version). See the LICENSE files for more information. \ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/build.readme.md b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/build.readme.md new file mode 100644 index 0000000..e69de29 diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/AppDataEntry.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/AppDataEntry.cs new file mode 100644 index 0000000..d6d936f --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/AppDataEntry.cs @@ -0,0 +1,54 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: AppDataEntry.cs +* +* AppDataEntry.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using VNLib.Utils.Logging; +using VNLib.Plugins.Extensions.Loading.Routing; + +using VNLib.Plugins.Essentials.Accounts.AppData.Endpoints; + +namespace VNLib.Plugins.Essentials.Accounts.AppData +{ + public sealed class AppDataEntry : PluginBase + { + /// + public override string PluginName => "Essentials.AppData"; + + /// + protected override void OnLoad() + { + this.Route(); + Log.Information("Plugin loaded"); + } + + /// + protected override void OnUnLoad() + { + Log.Information("Plugin unloaded"); + } + + /// + protected override void ProcessHostCommand(string cmd) + { } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/CacheStore.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/CacheStore.cs new file mode 100644 index 0000000..95c1b5a --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/CacheStore.cs @@ -0,0 +1,249 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: CacheStore.cs +* +* CacheStore.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.Buffers; +using System.Threading; +using System.Threading.Tasks; + +using MemoryPack; + +using VNLib.Utils.Extensions; +using VNLib.Utils.Logging; +using VNLib.Hashing.Checksums; +using VNLib.Data.Caching; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.VNCache; +using VNLib.Plugins.Extensions.VNCache.DataModel; +using VNLib.Plugins.Essentials.Accounts.AppData.Stores; +using VNLib.Plugins.Essentials.Accounts.AppData.Model; + +namespace VNLib.Plugins.Essentials.Accounts.AppData +{ + + [ConfigurationName("record_cache")] + internal sealed class CacheStore : IAppDataStore + { + const string LogScope = "Record Cache"; + + private readonly IEntityCache _cache; + private readonly PersistentStorageManager _backingStore; + private readonly ILogProvider _logger; + private readonly bool AlwaysObserverCacheUpdate; + private readonly TimeSpan CacheTTL; + + + public CacheStore(PluginBase plugin, IConfigScope config) + { + string cachePrefix = config.GetRequiredProperty("prefix", p => p.GetString()!); + CacheTTL = config.GetRequiredProperty("ttl", p => p.GetTimeSpan(TimeParseType.Seconds))!; + AlwaysObserverCacheUpdate = config.GetRequiredProperty("force_write_through", p => p.GetBoolean())!; + _logger = plugin.Log.CreateScope(LogScope); + + //Load persistent storage manager + _backingStore = plugin.GetOrCreateSingleton(); + + //Use memory pack for serialization + MpSerializer serializer = new(); + + /* + * Initialize entity cache from the default global cache provider, + * then create a prefixed cache for the app data records. + * + * The app should make sure that the cache provider is available + * otherwise do not load this component. + */ + _cache = plugin.GetDefaultGlobalCache() + ?.GetPrefixedCache(cachePrefix) + ?.CreateEntityCache(serializer, serializer) + ?? throw new InvalidOperationException("No cache provider is available"); + + _logger.Verbose("Cache and backing store initialized"); + } + + /// + public Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation) + { + /* + * Deleting entires does not matter if they existed previously or not. Just + * that the opeation executed successfully. + * + * Parallelize the delete operation to the cache and the backing store + */ + Task fromCache = _cache.RemoveAsync(GetCacheKey(userId, recordKey), cancellation); + Task fromDb = _backingStore.DeleteRecordAsync(userId, recordKey, cancellation); + + return Task.WhenAll(fromCache, fromDb); + } + + /// + public async Task GetRecordAsync(string userId, string recordKey, RecordOpFlags flags, CancellationToken cancellation) + { + bool useCache = (flags & RecordOpFlags.NoCache) == 0; + + //See if caller wants to bypass cache + if (useCache) + { + string cacheKey = GetCacheKey(userId, recordKey); + + //try fetching from cache + RecordDataCacheEntry? cached = await _cache.GetAsync(cacheKey, cancellation); + + //if cache is valid, return it + if (cached != null && !IsCacheExpired(cached)) + { + return new(userId, cached.RecordData, cached.UnixTimestamp, cached.Checksum); + } + } + + //fetch from db + UserRecordData? stored = await _backingStore.GetRecordAsync(userId, recordKey, flags, cancellation); + + //If the record is valid and cache is enabled, update the record in cache + if (useCache && stored is not null) + { + //If no checksum is present, calculate it before storing in cache + if (!stored.Checksum.HasValue) + { + ulong checksum = FNV1a.Compute64(stored.Data); + stored = stored with { Checksum = checksum }; + } + + //update cached version + Task update = DeferCacheUpdate( + userId, + recordKey, + stored.Data, + stored.LastModifed, + stored.Checksum.Value + ); + + if (AlwaysObserverCacheUpdate || (flags & RecordOpFlags.WriteThrough) != 0) + { + //Wait for cache update to complete + await update.ConfigureAwait(false); + } + else + { + //Defer the cache update and continue + WatchDeferredCacheUpdate(update); + } + } + + return stored; + } + + /// + public Task SetRecordAsync(string userId, string recordKey, byte[] data, ulong checksum, RecordOpFlags flags, CancellationToken cancellation) + { + + //Always push update to db + Task db = _backingStore.SetRecordAsync(userId, recordKey, data, checksum, flags, cancellation); + + //Optionally push update to cache + Task cache = Task.CompletedTask; + + if ((flags & RecordOpFlags.NoCache) == 0) + { + long time = DateTimeOffset.Now.ToUnixTimeSeconds(); + + //Push update to cache + cache = DeferCacheUpdate(userId, recordKey, data, time, checksum); + } + + /* + * If writethough is not set, updates will always be deferred + * and this call will return immediately. + * + * We still need to observe the task incase an error occurs + */ + Task all = Task.WhenAll(db, cache); + + if (AlwaysObserverCacheUpdate || (flags & RecordOpFlags.WriteThrough) != 0) + { + return all; + } + else + { + WatchDeferredCacheUpdate(all); + return Task.CompletedTask; + } + } + + private string GetCacheKey(string userId, string recordKey) => $"{userId}:{recordKey}"; + + private bool IsCacheExpired(RecordDataCacheEntry entry) + { + return DateTimeOffset.FromUnixTimeSeconds(entry.UnixTimestamp).Add(CacheTTL) < DateTimeOffset.Now; + } + + private Task DeferCacheUpdate(string userId, string recordKey, byte[] data, long time, ulong checksum) + { + string cacheKey = GetCacheKey(userId, recordKey); + + RecordDataCacheEntry entry = new() + { + Checksum = checksum, + RecordData = data, + UnixTimestamp = time + }; + + return _cache.UpsertAsync(cacheKey, entry); + } + + private async void WatchDeferredCacheUpdate(Task update) + { + try + { + await update.ConfigureAwait(false); + } + catch (Exception e) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.Warn(e, "Failed to update cached User AppData record"); + } + else + { + _logger.Warn("Failed to update cached AppData record"); + } + } + } + + + private sealed class MpSerializer : ICacheObjectDeserializer, ICacheObjectSerializer + { + + public T? Deserialize(ReadOnlySpan objectData) + { + return MemoryPackSerializer.Deserialize(objectData); + } + + public void Serialize(T obj, IBufferWriter finiteWriter) + { + MemoryPackSerializer.Serialize(finiteWriter, obj); + } + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Endpoints/WebEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Endpoints/WebEndpoint.cs new file mode 100644 index 0000000..9c8f501 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Endpoints/WebEndpoint.cs @@ -0,0 +1,203 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: WebEndpoint.cs +* +* WebEndpoint.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System.Net; +using System.Linq; +using System.Collections.Generic; +using System.Threading.Tasks; + +using VNLib.Net.Http; +using VNLib.Utils.Logging; +using VNLib.Hashing.Checksums; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.VNCache; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Validation; + +using VNLib.Plugins.Essentials.Accounts.AppData.Model; +using VNLib.Plugins.Essentials.Accounts.AppData.Stores; + +namespace VNLib.Plugins.Essentials.Accounts.AppData.Endpoints +{ + [ConfigurationName("web_endpoint")] + internal sealed class WebEndpoint : ProtectedWebEndpoint + { + const int DefaultMaxDataSize = 8 * 1024; + + private readonly IAppDataStore _store; + private readonly int MaxDataSize; + private readonly string[] AllowedScopes; + + public WebEndpoint(PluginBase plugin, IConfigScope config) + { + string path = config.GetRequiredProperty("path", p => p.GetString())!; + InitPathAndLog(path, plugin.Log.CreateScope("Endpoint")); + + MaxDataSize = config.GetValueOrDefault("max_data_size", p => p.GetInt32(), DefaultMaxDataSize); + AllowedScopes = config.GetRequiredProperty("allowed_scopes", p => p.EnumerateArray().Select(p => p.GetString()!)).ToArray(); + + bool useCache = false; + + //Cache loading is optional + if (plugin.HasConfigForType()) + { + //See if caching is enabled + IConfigScope cacheConfig = plugin.GetConfigForType(); + useCache = cacheConfig.GetValueOrDefault("enabled", e => e.GetBoolean(), false); + + if (useCache && plugin.GetDefaultGlobalCache() is null) + { + plugin.Log.Error("Cache was enabled but no caching library was loaded. Continuing without cache"); + useCache = false; + } + } + + _store = LoadStore(plugin, useCache); + } + + private static IAppDataStore LoadStore(PluginBase plugin, bool withCache) + { + return withCache + ? plugin.GetOrCreateSingleton() + : plugin.GetOrCreateSingleton(); + } + + protected async override ValueTask GetAsync(HttpEntity entity) + { + WebMessage webm = new(); + + string? scopeId = entity.QueryArgs.GetValueOrDefault("scope"); + bool noCache = entity.QueryArgs.ContainsKey("no_cache"); + + if (webm.Assert(scopeId != null, "Missing scope")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + if (webm.Assert(AllowedScopes.Contains(scopeId), "Invalid scope")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + //If the connection has the no-cache header set, also bypass the cache + noCache |= entity.Server.NoCache(); + + //optionally bypass cache if the user requests it + RecordOpFlags flags = noCache ? RecordOpFlags.NoCache : RecordOpFlags.None; + + UserRecordData? record = await _store.GetRecordAsync(entity.Session.UserID, scopeId, flags, entity.EventCancellation); + + if (record is null) + { + return VirtualClose(entity, webm, HttpStatusCode.NotFound); + } + + //return the raw data with the checksum header + entity.SetRecordResponse(record, HttpStatusCode.OK); + return VfReturnType.VirtualSkip; + } + + protected override async ValueTask PutAsync(HttpEntity entity) + { + WebMessage webm = new(); + string? scopeId = entity.QueryArgs.GetValueOrDefault("scope"); + bool flush = entity.QueryArgs.ContainsKey("flush"); + + if (webm.Assert(entity.Files.Count == 1, "Invalid file count")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + if (webm.Assert(scopeId != null, "Missing scope")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + if (webm.Assert(AllowedScopes.Contains(scopeId), "Invalid scope")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + FileUpload data = entity.Files[0]; + + if (webm.Assert(data.Length <= MaxDataSize, "Data too large")) + { + return VirtualClose(entity, webm, HttpStatusCode.RequestEntityTooLarge); + } + + byte[] recordData = new byte[data.Length]; + int read = await data.FileData.ReadAsync(recordData, entity.EventCancellation); + + if (webm.Assert(read == recordData.Length, "Failed to read data")) + { + return VirtualClose(entity, webm, HttpStatusCode.InternalServerError); + } + + //Compute checksum on sent data and compare to the header if it exists + ulong checksum = FNV1a.Compute64(recordData); + ulong? userChecksum = entity.Server.GetUserDataChecksum(); + + if (userChecksum.HasValue) + { + //compare the checksums + if (webm.Assert(checksum == userChecksum.Value, "Checksum mismatch")) + { + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); + } + } + + /* + * If the user specifies the flush flag, the call will wait until the entire record + * is published to the persistent store before returning. Typically if a caching layer is + * used, the record will be written to the cache and the call will return immediately. + */ + RecordOpFlags flags = flush ? RecordOpFlags.WriteThrough : RecordOpFlags.None; + + //Write the record to the store + await _store.SetRecordAsync(entity.Session.UserID, scopeId, recordData, checksum, flags, entity.EventCancellation); + return VirtualClose(entity, HttpStatusCode.Accepted); + } + + protected override async ValueTask DeleteAsync(HttpEntity entity) + { + WebMessage webm = new(); + string? scopeId = entity.QueryArgs.GetValueOrDefault("scope"); + + if (webm.Assert(scopeId != null, "Missing scope")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + if (webm.Assert(AllowedScopes.Contains(scopeId), "Invalid scope")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + //Write the record to the store + await _store.DeleteRecordAsync(entity.Session.UserID, scopeId, entity.EventCancellation); + return VirtualClose(entity, HttpStatusCode.Accepted); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/HttpExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/HttpExtensions.cs new file mode 100644 index 0000000..9628b79 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/HttpExtensions.cs @@ -0,0 +1,75 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: HttpExtensions.cs +* +* HttpExtensions.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.Net; + +using VNLib.Net.Http; + +namespace VNLib.Plugins.Essentials.Accounts.AppData.Model +{ + internal static class HttpExtensions + { + const string ChecksumHeader = "X-Data-Checksum"; + + public static void SetRecordResponse(this HttpEntity entity, UserRecordData record, HttpStatusCode code) + { + //Set checksum header + entity.Server.Headers.Append(ChecksumHeader, $"{record.Checksum}"); + + //Set the response to a new memory reader with the record data + entity.CloseResponse( + code, + ContentType.Binary, + new BinDataRecordReader(record.Data) + ); + } + + public static ulong? GetUserDataChecksum(this IConnectionInfo server) + { + string? checksumStr = server.Headers[ChecksumHeader]; + return string.IsNullOrWhiteSpace(checksumStr) && ulong.TryParse(checksumStr, out ulong checksum) ? checksum : null; + } + + sealed class BinDataRecordReader(byte[] recordData) : IMemoryResponseReader + { + private int _read; + + /// + public int Remaining => recordData.Length - _read; + + /// + public void Advance(int written) => _read += written; + + /// + public void Close() + { + //No-op + } + + /// + public ReadOnlyMemory GetMemory() => recordData.AsMemory(_read); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/IAppDataStore.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/IAppDataStore.cs new file mode 100644 index 0000000..5b38d21 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/IAppDataStore.cs @@ -0,0 +1,38 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: IAppDataStore.cs +* +* IAppDataStore.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System.Threading; +using System.Threading.Tasks; + +namespace VNLib.Plugins.Essentials.Accounts.AppData.Model +{ + internal interface IAppDataStore + { + Task GetRecordAsync(string userId, string recordKey, RecordOpFlags flags, CancellationToken cancellation); + + Task SetRecordAsync(string userId, string recordKey, byte[] data, ulong checksum, RecordOpFlags flags, CancellationToken cancellation); + + Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation); + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordDataCacheEntry.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordDataCacheEntry.cs new file mode 100644 index 0000000..9c0767d --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordDataCacheEntry.cs @@ -0,0 +1,38 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: RecordDataCacheEntry.cs +* +* RecordDataCacheEntry.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using MemoryPack; + +namespace VNLib.Plugins.Essentials.Accounts.AppData.Model +{ + [MemoryPackable] + internal partial class RecordDataCacheEntry + { + public byte[] RecordData { get; set; } + + public ulong? Checksum { get; set; } + + public long UnixTimestamp { get; set; } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordOpFlags.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordOpFlags.cs new file mode 100644 index 0000000..31d9840 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordOpFlags.cs @@ -0,0 +1,35 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: RecordOpFlags.cs +* +* RecordOpFlags.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + + +namespace VNLib.Plugins.Essentials.Accounts.AppData.Model +{ + internal enum RecordOpFlags + { + None = 0, + IgnoreChecksum = 1, + WriteThrough = 2, + NoCache = 4, + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/UserRecordData.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/UserRecordData.cs new file mode 100644 index 0000000..d3770c6 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/UserRecordData.cs @@ -0,0 +1,28 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: UserRecordData.cs +* +* UserRecordData.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +namespace VNLib.Plugins.Essentials.Accounts.AppData.Model +{ + internal record class UserRecordData(string UserId, byte[] Data, long LastModifed, ulong? Checksum); +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/PersistentStorageManager.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/PersistentStorageManager.cs new file mode 100644 index 0000000..99a3286 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/PersistentStorageManager.cs @@ -0,0 +1,75 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: PersistentStorageManager.cs +* +* PersistentStorageManager.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.Threading; +using System.Threading.Tasks; + +using VNLib.Utils.Logging; +using VNLib.Plugins.Extensions.Loading; + +using VNLib.Plugins.Essentials.Accounts.AppData.Model; +using VNLib.Plugins.Essentials.Accounts.AppData.Stores.Sql; + +namespace VNLib.Plugins.Essentials.Accounts.AppData.Stores +{ + [ConfigurationName("storage")] + internal sealed class PersistentStorageManager : IAppDataStore + { + private readonly IAppDataStore _backingStore; + + public PersistentStorageManager(PluginBase plugin, IConfigScope config) + { + string storeType = config.GetRequiredProperty("type", p => p.GetString()!).ToLower(null); + + switch (storeType) + { + case "sql": + _backingStore = plugin.GetOrCreateSingleton(); + plugin.Log.Information("Using SQL based backing store"); + break; + default: + throw new NotSupportedException($"Storage type {storeType} is not supported"); + } + } + + /// + public Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation) + { + return _backingStore.DeleteRecordAsync(userId, recordKey, cancellation); + } + + /// + public Task GetRecordAsync(string userId, string recordKey, RecordOpFlags flags, CancellationToken cancellation) + { + return _backingStore.GetRecordAsync(userId, recordKey, flags, cancellation); + } + + /// + public Task SetRecordAsync(string userId, string recordKey, byte[] data, ulong checksum, RecordOpFlags flags, CancellationToken cancellation) + { + return _backingStore.SetRecordAsync(userId, recordKey, data, checksum, flags, cancellation); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/DataRecord.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/DataRecord.cs new file mode 100644 index 0000000..f3be974 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/DataRecord.cs @@ -0,0 +1,59 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: DataRecord.cs +* +* DataRecord.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.ComponentModel.DataAnnotations; + +using VNLib.Plugins.Extensions.Data.Abstractions; +using VNLib.Plugins.Extensions.Data; + +namespace VNLib.Plugins.Essentials.Accounts.AppData.Stores.Sql +{ + +#nullable disable + internal sealed class DataRecord : DbModelBase, IUserEntity + { + [Key] + [MaxLength(64)] + public override string Id { get; set; } + + [MaxLength(64)] + public string RecordKey { get; set; } + + [MaxLength(64)] + public string UserId { get; set; } + + public override DateTime Created { get; set; } + + public override DateTime LastModified { get; set; } + + [MaxLength(int.MaxValue)] //Should defailt to MAX it set to very large number + public byte[] Data { get; set; } + + /// + /// The FNV-1a checksum of the data + /// + public long Checksum { get; set; } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/SqlBackingStore.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/SqlBackingStore.cs new file mode 100644 index 0000000..f67c652 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/SqlBackingStore.cs @@ -0,0 +1,144 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: SqlBackingStore.cs +* +* SqlBackingStore.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.EntityFrameworkCore; + +using VNLib.Utils.Logging; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Sql; +using VNLib.Plugins.Extensions.Data; +using VNLib.Plugins.Extensions.Data.Abstractions; +using VNLib.Plugins.Extensions.Data.Extensions; +using VNLib.Plugins.Essentials.Accounts.AppData.Model; + +namespace VNLib.Plugins.Essentials.Accounts.AppData.Stores.Sql +{ + + internal sealed class SqlBackingStore(PluginBase plugin) : IAppDataStore, IAsyncConfigurable + { + private readonly DbRecordStore _store = new(plugin.GetContextOptionsAsync()); + + /// + async Task IAsyncConfigurable.ConfigureServiceAsync(PluginBase plugin) + { + //Wait for the options to be ready + await _store.WhenLoaded(); + + //Add startup delay + await Task.Delay(2000); + + plugin.Log.Debug("Creating database tables for Account AppData"); + + await plugin.EnsureDbCreatedAsync(plugin); + } + + /// + public Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation) + { + return _store.DeleteAsync([userId, recordKey], cancellation); + } + + /// + public async Task GetRecordAsync(string userId, string recordKey, RecordOpFlags flags, CancellationToken cancellation) + { + DataRecord? dr = await _store.GetSingleAsync(userId, recordKey); + + if (dr is null) + { + return null; + } + + //get the last modified time in unix time for the caller + long lastModifed = new DateTimeOffset(dr.LastModified).ToUnixTimeSeconds(); + + return new(userId, dr.Data!, lastModifed, unchecked((ulong)dr.Checksum)); + } + + /// + public Task SetRecordAsync(string userId, string recordKey, byte[] data, ulong checksum, RecordOpFlags flags, CancellationToken cancellation) + { + return _store.AddOrUpdateAsync(new DataRecord + { + UserId = userId, + RecordKey = recordKey, + Data = data, + Checksum = unchecked((long)checksum) + }, cancellation); + } + + sealed class DbRecordStore(IAsyncLazy options) : DbStore + { + public async Task WhenLoaded() => await options; + + /// + public override IDbQueryLookup QueryTable { get; } = new DbQueries(); + + /// + public override IDbContextHandle GetNewContext() => new UserRecordDbContext(options.Value); + + /// + public override string GetNewRecordId() => Guid.NewGuid().ToString("N"); + + /// + public override void OnRecordUpdate(DataRecord newRecord, DataRecord existing) + { + existing.Data = newRecord.Data; + existing.Checksum = newRecord.Checksum; + existing.RecordKey = newRecord.RecordKey; + existing.UserId = newRecord.UserId; + existing.Created = newRecord.Created; + } + + sealed class DbQueries : IDbQueryLookup + { + public IQueryable GetCollectionQueryBuilder(IDbContextHandle context, params string[] constraints) + { + throw new NotSupportedException("Lists for users is not queryable. Callers must submit a record key"); + } + + public IQueryable GetSingleQueryBuilder(IDbContextHandle context, params string[] constraints) + { + string userId = constraints[0]; + string recordKey = constraints[1]; + + return from r in context.Set() + where r.UserId == userId && r.RecordKey == recordKey + select r; + } + + public IQueryable AddOrUpdateQueryBuilder(IDbContextHandle context, DataRecord record) + { + return GetSingleQueryBuilder(context, record.UserId!, record.RecordKey!); + } + } + } + + + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/UserRecordDbContext.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/UserRecordDbContext.cs new file mode 100644 index 0000000..1ab8767 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/UserRecordDbContext.cs @@ -0,0 +1,59 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts.AppData +* File: UserRecordDbContext.cs +* +* UserRecordDbContext.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using Microsoft.EntityFrameworkCore; + +using VNLib.Plugins.Extensions.Data; +using VNLib.Plugins.Extensions.Loading.Sql; + +namespace VNLib.Plugins.Essentials.Accounts.AppData.Stores.Sql +{ + internal sealed class UserRecordDbContext : DBContextBase, IDbTableDefinition + { + public DbSet UserDataRecords { get; set; } + + public UserRecordDbContext(DbContextOptions options) : base(options) + { } + + public UserRecordDbContext() + { } + + public void OnDatabaseCreating(IDbContextBuilder builder, object? userState) + { + //Define the table for the data records + builder.DefineTable(nameof(UserDataRecords), table => + { + //Define table columns + table.WithColumn(p => p.Id).AllowNull(false); + table.WithColumn(p => p.Version).TimeStamp(); + table.WithColumn(p => p.RecordKey).AllowNull(false); + table.WithColumn(p => p.UserId).AllowNull(false); + table.WithColumn(p => p.Created); + table.WithColumn(p => p.LastModified); + table.WithColumn(p => p.Data); + table.WithColumn(p => p.Checksum); + }); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj new file mode 100644 index 0000000..580faba --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj @@ -0,0 +1,67 @@ + + + + enable + net8.0 + VNLib.Plugins.Essentials.Accounts.AppData + Essentials.AppData + latest-all + en-US + true + + true + + + + VNLib.Plugins.Essentials.Accounts.AppData + Vaughn Nugent + Vaughn Nugent + Essentials Account-Based client data storage + Copyright © 2024 Vaughn Nugent + https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials + https://github.com/VnUgE/Plugins.Essentials/tree/master/plugins/VNLib.Plugins.Essentials.Accounts.AppData + An Essentials plugin that provides endpoints for web-application synchronized storage such as user preferences + + + + README.md + LICENSE + + + + + True + \ + Always + + + True + \ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs index 2e0c259..4f8bcd3 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs @@ -104,33 +104,40 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider { //Expired ExpireCookies(entity, true); - + //Verbose because this is a normal occurance if (_logger.IsEnabled(LogLevel.Verbose)) { _logger.Verbose("Session {id} expired", session.SessionID[..8]); } } - else + else if (session.IsNew) { - //See if the session might be elevated - if (!ClientWebAuthManager.IsSessionElevated(in session)) - { - //If the session stored a user-agent, make sure it matches the connection - if (session.UserAgent != null && !session.UserAgent.Equals(entity.Server.UserAgent, StringComparison.Ordinal)) - { - _logger.Debug("Denied authorized connection from {ip} because user-agent changed", entity.TrustedRemoteIp); - return ValueTask.FromResult(FileProcessArgs.Deny); - } - } - - //If the session is new, or not supposed to be logged in, clear the login cookies if they were set - if (session.IsNew || string.IsNullOrEmpty(session.Token)) + //explicitly expire cookies on new sessions + ExpireCookies(entity, false); + } + //See if the session might be elevated + else if (ClientWebAuthManager.IsSessionElevated(in session)) + { + //If the session stored a user-agent, make sure it matches the connection + if (session.UserAgent != null && !session.UserAgent.Equals(entity.Server.UserAgent, StringComparison.Ordinal)) { - //Do not force clear cookies (saves bandwidth) - ExpireCookies(entity, false); + _logger.Debug("Denied authorized connection from {ip} because user-agent changed", entity.TrustedRemoteIp); + return ValueTask.FromResult(FileProcessArgs.Deny); } } + else + { + /* + * Attempts to clear client cookies if the session is not elevated + * and the client may still have cookies set from a previous session + * + * Cookies are only sent if the client also sent login cookies to avoid + * sending cookies on every request + */ + ExpireCookies(entity, false); + } + } //Always continue otherwise @@ -147,7 +154,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider if (session.Created.AddSeconds(_config.WebSessionValidForSeconds) < entity.RequestedTimeUtc) { //Invalidate the session, so its technically valid for this request, but will be cleared on this handle close cycle - entity.Session.Invalidate(); + session.Invalidate(); //Clear auth specifc cookies _authManager.DestroyAuthorization(entity); @@ -169,7 +176,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider ArgumentNullException.ThrowIfNull(clientInfo.PublicKey, nameof(clientInfo.PublicKey)); ArgumentNullException.ThrowIfNull(clientInfo.ClientId, nameof(clientInfo.ClientId)); - if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web) + if (!IsSessionStateValid(in entity.Session)) { throw new ArgumentException("The session is no configured for authorization"); } @@ -189,7 +196,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider IClientAuthorization IAccountSecurityProvider.ReAuthorizeClient(HttpEntity entity) { //Confirm session is configured - if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web) + if (!IsSessionStateValid(in entity.Session)) { throw new InvalidOperationException("The session is not configured for authorization"); } @@ -219,7 +226,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider bool IAccountSecurityProvider.IsClientAuthorized(HttpEntity entity, AuthorzationCheckLevel level) { //Session must be loaded and not-new for an authorization to exist - if(!entity.Session.IsSet || entity.Session.IsNew) + if(!IsSessionStateValid(in entity.Session)) { return false; } @@ -249,7 +256,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider { //Use the public key supplied by the csecinfo return RsaClientDataEncryption.TryEncrypt(entity.PublicKey, data, outputBuffer); - } + } + + private static bool IsSessionStateValid(in SessionInfo session) => session.IsSet && !session.IsNew && session.SessionType == SessionType.Web; #endregion diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs index c4b0c26..2c2058d 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs @@ -34,6 +34,7 @@ using System; using System.Linq; using System.Text.Json; +using System.Diagnostics; using VNLib.Hashing; using VNLib.Hashing.IdentityUtility; @@ -286,7 +287,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider } //Get the client signature - string? base32Sig = GetSigningKey(in entity.Session); + string? base32Sig = GetSigningKey(in entity.Session); if (string.IsNullOrWhiteSpace(base32Sig)) { @@ -352,11 +353,12 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider private bool VerifyConnectionOTPInternal(HttpEntity entity) { - //Get the token from the client header, the client should always sent this - string? signedMessage = entity.Server.Headers[_config.TokenHeaderName]; + Debug.Assert(IsSessionValid(in entity.Session), "Session was assumed to be valid for this call"); - //Make sure a session is loaded - if (!entity.Session.IsSet || entity.Session.IsNew || string.IsNullOrWhiteSpace(signedMessage)) + //Get the token from the client header, the client should always sent this + string? signedMessage = GetOTPHeaderValue(entity); + + if (string.IsNullOrWhiteSpace(signedMessage)) { return false; } @@ -540,6 +542,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider => string.IsNullOrWhiteSpace(GetLoginToken(in session)) == false; private void SetPubkeyCookie(HttpEntity entity, string value) => _pubkeyCookie.SetCookie(entity, value); + private string? GetOTPHeaderValue(HttpEntity entity) => entity.Server.Headers[_config.TokenHeaderName]; private static void SetSigningKey(ref readonly SessionInfo session, string? value) => session[PUBLIC_KEY_SIG_KEY_ENTRY] = value!; private static void SetLoginToken(ref readonly SessionInfo session, string? value) => session[LOGIN_TOKEN_ENTRY] = value!; -- cgit