aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2024-04-08 22:04:04 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2024-04-08 22:04:04 -0400
commita7cf7c8987b8847984629293d8eb27908f3de3dd (patch)
tree62dfb4864c45b683ba58ccc41d8277ce5f8ce911
parent27b487b6d0befdb2197a58ceadb1f1ac2b337786 (diff)
Squashed commit of the following:
commit 44803e06d1aa45496c04127930aa8897272d42f6 Author: vnugent <public@vaughnnugent.com> Date: Mon Apr 8 21:41:38 2024 -0400 fix: dangling/expired session security check and cookie cleanup commit 1082bd146549a1aff47877bcd28e6be1ce0ef5e9 Author: vnugent <public@vaughnnugent.com> 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 <public@vaughnnugent.com> Date: Sun Mar 24 21:16:05 2024 -0400 Merge branch 'master' into develop commit 2a114541a3bfddae887adaa98c1ed326b125d511 Author: vnugent <public@vaughnnugent.com> Date: Sun Mar 24 20:53:38 2024 -0400 refactor: pull apart session authorization for future dev commit f8aea6453ddb2d56c1ce2ecb6a9e67d1af523c2e Author: vnugent <public@vaughnnugent.com> Date: Thu Mar 21 14:33:21 2024 -0400 feat: Add optional svg base64 icons for social OAuth2 connections commit cc29bed99dc9e151315cce75e50d55dca306b532 Author: vnugent <public@vaughnnugent.com> Date: Sun Mar 10 21:58:27 2024 -0400 source tree project location updated
-rw-r--r--lib/vnlib.browser/package-lock.json176
-rw-r--r--lib/vnlib.browser/package.json4
-rw-r--r--lib/vnlib.browser/src/app-data/index.ts147
-rw-r--r--lib/vnlib.browser/src/index.ts3
-rw-r--r--lib/vnlib.browser/src/social/index.ts19
-rw-r--r--plugins.essentials.build.sln6
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md18
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/build.readme.md0
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/AppDataEntry.cs54
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/CacheStore.cs249
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Endpoints/WebEndpoint.cs203
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/HttpExtensions.cs75
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/IAppDataStore.cs38
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordDataCacheEntry.cs38
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordOpFlags.cs35
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/UserRecordData.cs28
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/PersistentStorageManager.cs75
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/DataRecord.cs59
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/SqlBackingStore.cs144
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/UserRecordDbContext.cs59
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj67
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs53
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs13
23 files changed, 1446 insertions, 117 deletions
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<T>(scope: string, noCache: boolean): Promise<T | undefined>
+ /**
+ * 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<T>(scope: string, data: T, wait: boolean): Promise<void>
+ /**
+ * Completely removes data from the app-data server
+ * @param scope The scope of the data to remove from the store
+ */
+ remove(scope: string): Promise<void>
+}
+
+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<T>(noCache: boolean): Promise<T | undefined>
+ /**
+ * 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<T>(data: T, wait: boolean): Promise<void>
+ /**
+ * 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<void>
+}
+
+/* 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<string>, 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 <T>(scope: string, noCache: boolean): Promise<T | undefined> => {
+ try {
+ const { data } = await axios!.get<T>(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 <T>(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<string>, scope: MaybeRef<string>, axios?: Axios): ScopedUserAppDataApi => {
+ const api = useAppDataApi(endpoint, axios);
+
+ return {
+ get: <T>(noCache: boolean) => api.get<T>(get(scope), noCache),
+ set: <T>(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<string[]>(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
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/build.readme.md
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
+ {
+ ///<inheritdoc/>
+ public override string PluginName => "Essentials.AppData";
+
+ ///<inheritdoc/>
+ protected override void OnLoad()
+ {
+ this.Route<WebEndpoint>();
+ Log.Information("Plugin loaded");
+ }
+
+ ///<inheritdoc/>
+ protected override void OnUnLoad()
+ {
+ Log.Information("Plugin unloaded");
+ }
+
+ ///<inheritdoc/>
+ 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<RecordDataCacheEntry> _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<PersistentStorageManager>();
+
+ //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<RecordDataCacheEntry>(serializer, serializer)
+ ?? throw new InvalidOperationException("No cache provider is available");
+
+ _logger.Verbose("Cache and backing store initialized");
+ }
+
+ ///<inheritdoc/>
+ 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);
+ }
+
+ ///<inheritdoc/>
+ public async Task<UserRecordData?> 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;
+ }
+
+ ///<inheritdoc/>
+ 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<T>(ReadOnlySpan<byte> objectData)
+ {
+ return MemoryPackSerializer.Deserialize<T>(objectData);
+ }
+
+ public void Serialize<T>(T obj, IBufferWriter<byte> 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<CacheStore>())
+ {
+ //See if caching is enabled
+ IConfigScope cacheConfig = plugin.GetConfigForType<CacheStore>();
+ 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<CacheStore>()
+ : plugin.GetOrCreateSingleton<PersistentStorageManager>();
+ }
+
+ protected async override ValueTask<VfReturnType> 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<VfReturnType> 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<VfReturnType> 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;
+
+ ///<inheritdoc/>
+ public int Remaining => recordData.Length - _read;
+
+ ///<inheritdoc/>
+ public void Advance(int written) => _read += written;
+
+ ///<inheritdoc/>
+ public void Close()
+ {
+ //No-op
+ }
+
+ ///<inheritdoc/>
+ public ReadOnlyMemory<byte> 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<UserRecordData?> 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<SqlBackingStore>();
+ plugin.Log.Information("Using SQL based backing store");
+ break;
+ default:
+ throw new NotSupportedException($"Storage type {storeType} is not supported");
+ }
+ }
+
+ ///<inheritdoc/>
+ public Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation)
+ {
+ return _backingStore.DeleteRecordAsync(userId, recordKey, cancellation);
+ }
+
+ ///<inheritdoc/>
+ public Task<UserRecordData?> GetRecordAsync(string userId, string recordKey, RecordOpFlags flags, CancellationToken cancellation)
+ {
+ return _backingStore.GetRecordAsync(userId, recordKey, flags, cancellation);
+ }
+
+ ///<inheritdoc/>
+ 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; }
+
+ /// <summary>
+ /// The FNV-1a checksum of the data
+ /// </summary>
+ 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());
+
+ ///<inheritdoc/>
+ 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<UserRecordDbContext>(plugin);
+ }
+
+ ///<inheritdoc/>
+ public Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation)
+ {
+ return _store.DeleteAsync([userId, recordKey], cancellation);
+ }
+
+ ///<inheritdoc/>
+ public async Task<UserRecordData?> 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));
+ }
+
+ ///<inheritdoc/>
+ 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<DbContextOptions> options) : DbStore<DataRecord>
+ {
+ public async Task WhenLoaded() => await options;
+
+ ///<inheritdoc/>
+ public override IDbQueryLookup<DataRecord> QueryTable { get; } = new DbQueries();
+
+ ///<inheritdoc/>
+ public override IDbContextHandle GetNewContext() => new UserRecordDbContext(options.Value);
+
+ ///<inheritdoc/>
+ public override string GetNewRecordId() => Guid.NewGuid().ToString("N");
+
+ ///<inheritdoc/>
+ 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<DataRecord>
+ {
+ public IQueryable<DataRecord> GetCollectionQueryBuilder(IDbContextHandle context, params string[] constraints)
+ {
+ throw new NotSupportedException("Lists for users is not queryable. Callers must submit a record key");
+ }
+
+ public IQueryable<DataRecord> GetSingleQueryBuilder(IDbContextHandle context, params string[] constraints)
+ {
+ string userId = constraints[0];
+ string recordKey = constraints[1];
+
+ return from r in context.Set<DataRecord>()
+ where r.UserId == userId && r.RecordKey == recordKey
+ select r;
+ }
+
+ public IQueryable<DataRecord> 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<DataRecord> 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<DataRecord>(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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <Nullable>enable</Nullable>
+ <TargetFramework>net8.0</TargetFramework>
+ <RootNamespace>VNLib.Plugins.Essentials.Accounts.AppData</RootNamespace>
+ <AssemblyName>Essentials.AppData</AssemblyName>
+ <AnalysisLevel>latest-all</AnalysisLevel>
+ <NeutralLanguage>en-US</NeutralLanguage>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <!--Enable dynamic loading-->
+ <EnableDynamicLoading>true</EnableDynamicLoading>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <PackageId>VNLib.Plugins.Essentials.Accounts.AppData</PackageId>
+ <Authors>Vaughn Nugent</Authors>
+ <Company>Vaughn Nugent</Company>
+ <Product>Essentials Account-Based client data storage</Product>
+ <Copyright>Copyright © 2024 Vaughn Nugent</Copyright>
+ <PackageProjectUrl>https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials</PackageProjectUrl>
+ <RepositoryUrl>https://github.com/VnUgE/Plugins.Essentials/tree/master/plugins/VNLib.Plugins.Essentials.Accounts.AppData</RepositoryUrl>
+ <Description>An Essentials plugin that provides endpoints for web-application synchronized storage such as user preferences</Description>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <PackageReadmeFile>README.md</PackageReadmeFile>
+ <PackageLicenseFile>LICENSE</PackageLicenseFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <None Include="..\..\..\LICENSE">
+ <Pack>True</Pack>
+ <PackagePath>\</PackagePath>
+ <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ </None>
+ <None Include="..\README.md">
+ <Pack>True</Pack>
+ <PackagePath>\</PackagePath>
+ </None>
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="MemoryPack" Version="1.20.3" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\..\VNLib.Data.Caching\lib\VNLib.Plugins.Extensions.VNCache\src\VNLib.Plugins.Extensions.VNCache.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Data\src\VNLib.Plugins.Extensions.Data.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Loading.Sql\src\VNLib.Plugins.Extensions.Loading.Sql.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Validation\src\VNLib.Plugins.Extensions.Validation.csproj" />
+ </ItemGroup>
+
+
+ <Target Condition="'$(BuildingInsideVisualStudio)' == true" Name="PostBuild" AfterTargets="PostBuildEvent">
+ <Exec Command="start xcopy &quot;$(TargetDir)&quot; &quot;$(SolutionDir)devplugins\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+</Project>
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!;