diff --git a/package-lock.json b/package-lock.json
index 44cf298..b1994b2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,7 @@
"eslint": "^8.43.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.0.0",
+ "eslint-plugin-import": "^2.29.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.0.0",
"nodemon": "^2.0.20",
@@ -391,8 +392,7 @@
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/@types/lodash": {
"version": "4.14.197",
@@ -617,7 +617,6 @@
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz",
"integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==",
"dev": true,
- "peer": true,
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -637,7 +636,6 @@
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz",
"integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==",
"dev": true,
- "peer": true,
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -657,7 +655,6 @@
"resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz",
"integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==",
"dev": true,
- "peer": true,
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -676,7 +673,6 @@
"resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz",
"integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==",
"dev": true,
- "peer": true,
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -1552,7 +1548,6 @@
"resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz",
"integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==",
"dev": true,
- "peer": true,
"dependencies": {
"has": "^1.0.3"
}
@@ -1698,7 +1693,6 @@
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
"integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
"dev": true,
- "peer": true,
"dependencies": {
"debug": "^3.2.7",
"is-core-module": "^2.13.0",
@@ -1710,7 +1704,6 @@
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz",
"integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==",
"dev": true,
- "peer": true,
"dependencies": {
"debug": "^3.2.7"
},
@@ -1743,27 +1736,26 @@
}
},
"node_modules/eslint-plugin-import": {
- "version": "2.28.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.1.tgz",
- "integrity": "sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==",
+ "version": "2.29.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz",
+ "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==",
"dev": true,
- "peer": true,
"dependencies": {
- "array-includes": "^3.1.6",
- "array.prototype.findlastindex": "^1.2.2",
- "array.prototype.flat": "^1.3.1",
- "array.prototype.flatmap": "^1.3.1",
+ "array-includes": "^3.1.7",
+ "array.prototype.findlastindex": "^1.2.3",
+ "array.prototype.flat": "^1.3.2",
+ "array.prototype.flatmap": "^1.3.2",
"debug": "^3.2.7",
"doctrine": "^2.1.0",
- "eslint-import-resolver-node": "^0.3.7",
+ "eslint-import-resolver-node": "^0.3.9",
"eslint-module-utils": "^2.8.0",
- "has": "^1.0.3",
- "is-core-module": "^2.13.0",
+ "hasown": "^2.0.0",
+ "is-core-module": "^2.13.1",
"is-glob": "^4.0.3",
"minimatch": "^3.1.2",
- "object.fromentries": "^2.0.6",
- "object.groupby": "^1.0.0",
- "object.values": "^1.1.6",
+ "object.fromentries": "^2.0.7",
+ "object.groupby": "^1.0.1",
+ "object.values": "^1.1.7",
"semver": "^6.3.1",
"tsconfig-paths": "^3.14.2"
},
@@ -1779,7 +1771,6 @@
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
"dev": true,
- "peer": true,
"dependencies": {
"esutils": "^2.0.2"
},
@@ -1792,7 +1783,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
- "peer": true,
"bin": {
"semver": "bin/semver.js"
}
@@ -2336,11 +2326,6 @@
"node": ">= 0.6"
}
},
- "node_modules/express/node_modules/function-bind": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
- "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
- },
"node_modules/express/node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -2839,9 +2824,12 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"node_modules/function-bind": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
- "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
},
"node_modules/function.prototype.name": {
"version": "1.1.6",
@@ -3159,6 +3147,18 @@
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="
},
+ "node_modules/hasown": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
+ "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/html-entities": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz",
@@ -3479,12 +3479,12 @@
}
},
"node_modules/is-core-module": {
- "version": "2.13.0",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz",
- "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==",
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
+ "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
"dev": true,
"dependencies": {
- "has": "^1.0.3"
+ "hasown": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3788,7 +3788,6 @@
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true,
- "peer": true,
"dependencies": {
"minimist": "^1.2.0"
},
@@ -4556,7 +4555,6 @@
"resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz",
"integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==",
"dev": true,
- "peer": true,
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -4574,7 +4572,6 @@
"resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz",
"integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==",
"dev": true,
- "peer": true,
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -4587,7 +4584,6 @@
"resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz",
"integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==",
"dev": true,
- "peer": true,
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -5705,7 +5701,6 @@
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=4"
}
@@ -5855,7 +5850,6 @@
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz",
"integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==",
"dev": true,
- "peer": true,
"dependencies": {
"@types/json5": "^0.0.29",
"json5": "^1.0.2",
@@ -6428,8 +6422,7 @@
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
- "dev": true,
- "peer": true
+ "dev": true
},
"@types/lodash": {
"version": "4.14.197",
@@ -6600,7 +6593,6 @@
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz",
"integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==",
"dev": true,
- "peer": true,
"requires": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -6614,7 +6606,6 @@
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz",
"integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==",
"dev": true,
- "peer": true,
"requires": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -6628,7 +6619,6 @@
"resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz",
"integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==",
"dev": true,
- "peer": true,
"requires": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -6641,7 +6631,6 @@
"resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz",
"integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==",
"dev": true,
- "peer": true,
"requires": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -7292,7 +7281,6 @@
"resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz",
"integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==",
"dev": true,
- "peer": true,
"requires": {
"has": "^1.0.3"
}
@@ -7452,7 +7440,6 @@
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
"integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
"dev": true,
- "peer": true,
"requires": {
"debug": "^3.2.7",
"is-core-module": "^2.13.0",
@@ -7464,7 +7451,6 @@
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz",
"integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==",
"dev": true,
- "peer": true,
"requires": {
"debug": "^3.2.7"
}
@@ -7480,27 +7466,26 @@
}
},
"eslint-plugin-import": {
- "version": "2.28.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.1.tgz",
- "integrity": "sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==",
+ "version": "2.29.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz",
+ "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==",
"dev": true,
- "peer": true,
"requires": {
- "array-includes": "^3.1.6",
- "array.prototype.findlastindex": "^1.2.2",
- "array.prototype.flat": "^1.3.1",
- "array.prototype.flatmap": "^1.3.1",
+ "array-includes": "^3.1.7",
+ "array.prototype.findlastindex": "^1.2.3",
+ "array.prototype.flat": "^1.3.2",
+ "array.prototype.flatmap": "^1.3.2",
"debug": "^3.2.7",
"doctrine": "^2.1.0",
- "eslint-import-resolver-node": "^0.3.7",
+ "eslint-import-resolver-node": "^0.3.9",
"eslint-module-utils": "^2.8.0",
- "has": "^1.0.3",
- "is-core-module": "^2.13.0",
+ "hasown": "^2.0.0",
+ "is-core-module": "^2.13.1",
"is-glob": "^4.0.3",
"minimatch": "^3.1.2",
- "object.fromentries": "^2.0.6",
- "object.groupby": "^1.0.0",
- "object.values": "^1.1.6",
+ "object.fromentries": "^2.0.7",
+ "object.groupby": "^1.0.1",
+ "object.values": "^1.1.7",
"semver": "^6.3.1",
"tsconfig-paths": "^3.14.2"
},
@@ -7510,7 +7495,6 @@
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
"dev": true,
- "peer": true,
"requires": {
"esutils": "^2.0.2"
}
@@ -7519,8 +7503,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
- "peer": true
+ "dev": true
}
}
},
@@ -7807,11 +7790,6 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="
},
- "function-bind": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
- "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
- },
"http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -8244,9 +8222,9 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"function-bind": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
- "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
},
"function.prototype.name": {
"version": "1.1.6",
@@ -8468,6 +8446,15 @@
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="
},
+ "hasown": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
+ "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.2"
+ }
+ },
"html-entities": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz",
@@ -8702,12 +8689,12 @@
"dev": true
},
"is-core-module": {
- "version": "2.13.0",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz",
- "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==",
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
+ "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
"dev": true,
"requires": {
- "has": "^1.0.3"
+ "hasown": "^2.0.0"
}
},
"is-date-object": {
@@ -8908,7 +8895,6 @@
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true,
- "peer": true,
"requires": {
"minimist": "^1.2.0"
}
@@ -9466,7 +9452,6 @@
"resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz",
"integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==",
"dev": true,
- "peer": true,
"requires": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -9478,7 +9463,6 @@
"resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz",
"integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==",
"dev": true,
- "peer": true,
"requires": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -9491,7 +9475,6 @@
"resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz",
"integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==",
"dev": true,
- "peer": true,
"requires": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
@@ -10259,8 +10242,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
- "dev": true,
- "peer": true
+ "dev": true
},
"strip-final-newline": {
"version": "3.0.0",
@@ -10362,7 +10344,6 @@
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz",
"integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==",
"dev": true,
- "peer": true,
"requires": {
"@types/json5": "^0.0.29",
"json5": "^1.0.2",
diff --git a/package.json b/package.json
index c3f5cd7..7b1b551 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"csv-stringify": "^6.4.2",
"dotenv": "^16.0.3",
"es6-promisify": "^7.0.0",
+ "escape-html": "^1.0.3",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"express-handlebars": "^6.0.7",
@@ -26,8 +27,7 @@
"open-graph-scraper": "^5.2.3",
"sqlite": "^5.0.1",
"sqlite3": "^5.1.5",
- "string-strip-html": "^13.4.2",
- "escape-html": "^1.0.3"
+ "string-strip-html": "^13.4.2"
},
"engines": {
"node": ">=16.x"
@@ -49,6 +49,7 @@
"eslint": "^8.43.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.0.0",
+ "eslint-plugin-import": "^2.29.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.0.0",
"nodemon": "^2.0.20",
diff --git a/server.js b/server.js
index c2d7137..f6c6c7a 100644
--- a/server.js
+++ b/server.js
@@ -4,10 +4,11 @@ import cors from 'cors';
import { create } from 'express-handlebars';
import escapeHTML from 'escape-html';
-import { domain, account, simpleLogger, actorInfo, replaceEmptyText } from './src/util.js';
+import { domain, simpleLogger, getActorInfo, replaceEmptyText } from './src/util.js';
import session, { isAuthenticated } from './src/session-auth.js';
import * as bookmarksDb from './src/bookmarks-db.js';
-import * as apDb from './src/activity-pub-db.js';
+import * as db from './src/database.js';
+import './src/boot.js';
import routes from './src/routes/index.js';
@@ -22,15 +23,15 @@ app.use(express.json());
app.use(express.json({ type: 'application/activity+json' }));
app.use(session());
-app.use((req, res, next) => {
+app.use(async (req, res, next) => {
res.locals.loggedIn = req.session.loggedIn;
+ const { displayName } = await getActorInfo();
+ res.locals.siteName = displayName;
return next();
});
-app.set('site_name', actorInfo.displayName || 'Postmarks');
app.set('bookmarksDb', bookmarksDb);
-app.set('apDb', apDb);
-app.set('account', account);
+app.set('db', db);
app.set('domain', domain);
app.disable('x-powered-by');
@@ -61,12 +62,6 @@ const hbs = create({
const returnText = escapeHTML(text);
return returnText?.replace('\n', '
');
},
- siteName() {
- return app.get('site_name');
- },
- account() {
- return app.get('account');
- },
feedUrl() {
return `https://${app.get('domain')}/index.xml`;
},
diff --git a/src/activity-pub-db.js b/src/activity-pub-db.js
deleted file mode 100644
index f26fdf3..0000000
--- a/src/activity-pub-db.js
+++ /dev/null
@@ -1,254 +0,0 @@
-/**
- * Module handles activitypub data management
- *
- * Server API calls the methods in here to query and update the SQLite database
- */
-
-// Utilities we need
-import * as path from 'path';
-import fs from 'fs';
-import sqlite3 from 'sqlite3';
-import { open } from 'sqlite';
-import crypto from 'crypto';
-import { account, domain, actorInfo } from './util.js';
-
-const dbFile = './.data/activitypub.db';
-let db;
-
-function actorJson(pubkey) {
- return {
- '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
-
- id: `https://${domain}/u/${account}`,
- type: 'Person',
- preferredUsername: `${account}`,
- name: actorInfo.displayName,
- summary: actorInfo.description,
- icon: {
- type: 'Image',
- mediaType: `image/${path.extname(actorInfo.avatar).slice(1)}`,
- url: actorInfo.avatar,
- },
- inbox: `https://${domain}/api/inbox`,
- outbox: `https://${domain}/u/${account}/outbox`,
- followers: `https://${domain}/u/${account}/followers`,
- following: `https://${domain}/u/${account}/following`,
-
- publicKey: {
- id: `https://${domain}/u/${account}#main-key`,
- owner: `https://${domain}/u/${account}`,
- publicKeyPem: pubkey,
- },
- };
-}
-
-function webfingerJson() {
- return {
- subject: `acct:${account}@${domain}`,
-
- links: [
- {
- rel: 'self',
- type: 'application/activity+json',
- href: `https://${domain}/u/${account}`,
- },
- ],
- };
-}
-
-export async function getFollowers() {
- const result = await db?.get('select followers from accounts limit 1');
- return result?.followers;
-}
-
-export async function setFollowers(followersJson) {
- return db?.run('update accounts set followers=?', followersJson);
-}
-
-export async function getFollowing() {
- const result = await db?.get('select following from accounts limit 1');
- return result?.following;
-}
-
-export async function setFollowing(followingJson) {
- return db?.run('update accounts set following=?', followingJson);
-}
-
-export async function getBlocks() {
- const result = await db?.get('select blocks from accounts limit 1');
- return result?.blocks;
-}
-
-export async function setBlocks(blocksJson) {
- return db?.run('update accounts set blocks=?', blocksJson);
-}
-
-export async function getActor() {
- const result = await db?.get('select actor from accounts limit 1');
- return result?.actor;
-}
-
-export async function getWebfinger() {
- const result = await db?.get('select webfinger from accounts limit 1');
- return result?.webfinger;
-}
-
-export async function getPublicKey() {
- const result = await db?.get('select pubkey from accounts limit 1');
- return result?.pubkey;
-}
-
-export async function getPrivateKey() {
- const result = await db?.get('select privkey from accounts limit 1');
- return result?.privkey;
-}
-
-export async function getGuidForBookmarkId(id) {
- return (await db?.get('select guid from messages where bookmark_id = ?', id))?.guid;
-}
-
-export async function getBookmarkIdFromMessageGuid(guid) {
- return (await db?.get('select bookmark_id from messages where guid = ?', guid))?.bookmark_id;
-}
-
-export async function getMessage(guid) {
- return db?.get('select message from messages where guid = ?', guid);
-}
-
-export async function getMessageCount() {
- return (await db?.get('select count(message) as count from messages'))?.count;
-}
-
-export async function getMessages(offset = 0, limit = 20) {
- return db?.all('select message from messages order by bookmark_id desc limit ? offset ?', limit, offset);
-}
-
-export async function findMessageGuid(bookmarkId) {
- return (await db?.get('select guid from messages where bookmark_id = ?', bookmarkId))?.guid;
-}
-
-export async function deleteMessage(guid) {
- await db?.get('delete from messages where guid = ?', guid);
-}
-
-export async function getGlobalPermissions() {
- return db?.get('select * from permissions where bookmark_id = 0');
-}
-
-export async function setPermissionsForBookmark(id, allowed, blocked) {
- return db?.run('insert or replace into permissions(bookmark_id, allowed, blocked) values (?, ?, ?)', id, allowed, blocked);
-}
-
-export async function setGlobalPermissions(allowed, blocked) {
- return setPermissionsForBookmark(0, allowed, blocked);
-}
-
-export async function getPermissionsForBookmark(id) {
- return db?.get('select * from permissions where bookmark_id = ?', id);
-}
-
-export async function insertMessage(guid, bookmarkId, json) {
- return db?.run('insert or replace into messages(guid, bookmark_id, message) values(?, ?, ?)', guid, bookmarkId, json);
-}
-
-export async function findMessage(object) {
- return db?.all('select * from messages where message like ?', `%${object}%`);
-}
-
-async function firstTimeSetup(actorName) {
- // eslint-disable-next-line no-bitwise
- const newDb = new sqlite3.Database(dbFile, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, (err) => {
- if (err) {
- throw new Error(`unable to open or create database: ${err}`);
- }
- });
-
- newDb.close();
-
- // now do it again, using the async/await library
- await open({
- filename: dbFile,
- driver: sqlite3.Database,
- }).then(async (dBase) => {
- db = dBase;
- });
-
- await db.run(
- 'CREATE TABLE IF NOT EXISTS accounts (name TEXT PRIMARY KEY, privkey TEXT, pubkey TEXT, webfinger TEXT, actor TEXT, followers TEXT, following TEXT, messages TEXT, blocks TEXT)',
- );
-
- // if there is no `messages` table in the DB, create an empty table
- // TODO: index messages on bookmark_id
- await db.run('CREATE TABLE IF NOT EXISTS messages (guid TEXT PRIMARY KEY, message TEXT, bookmark_id INTEGER)');
- await db.run('CREATE TABLE IF NOT EXISTS permissions (bookmark_id INTEGER NOT NULL UNIQUE, allowed TEXT, blocked TEXT)');
-
- return new Promise((resolve, reject) => {
- crypto.generateKeyPair(
- 'rsa',
- {
- modulusLength: 4096,
- publicKeyEncoding: {
- type: 'spki',
- format: 'pem',
- },
- privateKeyEncoding: {
- type: 'pkcs8',
- format: 'pem',
- },
- },
- async (err, publicKey, privateKey) => {
- if (err) return reject(err);
- try {
- const actorRecord = actorJson(publicKey);
- const webfingerRecord = webfingerJson();
-
- await db.run(
- 'INSERT OR REPLACE INTO accounts (name, actor, pubkey, privkey, webfinger) VALUES (?, ?, ?, ?, ?)',
- actorName,
- JSON.stringify(actorRecord),
- publicKey,
- privateKey,
- JSON.stringify(webfingerRecord),
- );
- return resolve();
- } catch (e) {
- return reject(e);
- }
- },
- );
- });
-}
-
-function setup() {
- // activitypub not set up yet, skip until we have the data we need
- if (actorInfo.disabled) {
- return;
- }
-
- // Initialize the database
- const exists = fs.existsSync(dbFile);
-
- open({
- filename: dbFile,
- driver: sqlite3.Database,
- }).then(async (dBase) => {
- db = dBase;
-
- const actorName = `${account}@${domain}`;
-
- try {
- if (!exists) {
- await firstTimeSetup(actorName);
- }
-
- // re-run the profile portion of the actor setup every time in case the avatar, description, etc have changed
- const publicKey = await getPublicKey();
- const actorRecord = actorJson(publicKey);
- await db.run('UPDATE accounts SET name = ?, actor = ?', actorName, JSON.stringify(actorRecord));
- } catch (dbError) {
- console.error(dbError);
- }
- });
-}
-
-setup();
diff --git a/src/activitypub.js b/src/activitypub.js
index 09fd880..523558f 100644
--- a/src/activitypub.js
+++ b/src/activitypub.js
@@ -3,13 +3,14 @@ import crypto from 'crypto';
import escapeHTML from 'escape-html';
import { signedGetJSON, signedPostJSON } from './signature.js';
-import { actorInfo, actorMatchesUsername, replaceEmptyText } from './util.js';
+import { actorMatchesUsername, replaceEmptyText } from './util.js';
+import * as db from './database.js';
function getGuidFromPermalink(urlString) {
return urlString.match(/(?:\/m\/)([a-zA-Z0-9+/]+)/)[1];
}
-export async function signAndSend(message, name, domain, db, targetDomain, inbox) {
+export async function signAndSend(message, name, domain, targetDomain, inbox) {
try {
const response = await signedPostJSON(inbox, {
body: JSON.stringify(message),
@@ -80,7 +81,7 @@ export function createNoteObject(bookmark, account, domain) {
return noteMessage;
}
-function createMessage(noteObject, bookmarkId, account, domain, db) {
+async function createMessage(noteObject, bookmarkId, account, domain) {
const guidCreate = crypto.randomBytes(16).toString('hex');
const message = {
@@ -92,40 +93,48 @@ function createMessage(noteObject, bookmarkId, account, domain, db) {
object: noteObject,
};
- db.insertMessage(getGuidFromPermalink(noteObject.id), bookmarkId, JSON.stringify(noteObject));
+ // TODO: does "insert or replace" work?
+ await db.run(
+ `
+ insert or replace into messages
+ (guid, bookmark_id, message)
+ values (?, ?, ?)
+ `,
+ getGuidFromPermalink(noteObject.id),
+ bookmarkId,
+ JSON.stringify(noteObject),
+ );
return message;
}
-async function createUpdateMessage(bookmark, account, domain, db) {
- const guid = await db.getGuidForBookmarkId(bookmark.id);
+async function createUpdateMessage(bookmark, account, domain) {
+ const guid = (await db.get('select guid from messages where bookmark_id = ?', bookmark.id))?.guid;
+
+ let note = `https://${domain}/m/${guid}`;
// if the bookmark was created but not published to activitypub
// we might need to just make our own note object to send along
- let note;
if (guid === undefined) {
note = createNoteObject(bookmark, account, domain);
- createMessage(note, bookmark.id, account, domain, db);
- } else {
- note = `https://${domain}/m/${guid}`;
+ await createMessage(note, bookmark.id, account, domain);
}
- const updateMessage = {
+ return {
'@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
summary: `${account} updated the bookmark`,
type: 'Create', // this should be 'Update' but Mastodon does weird things with Updates
actor: `https://${domain}/u/${account}`,
object: note,
};
-
- return updateMessage;
}
-async function createDeleteMessage(bookmark, account, domain, db) {
- const guid = await db.findMessageGuid(bookmark.id);
- await db.deleteMessage(guid);
+async function createDeleteMessage(bookmark, account, domain) {
+ const guid = (await db.get('select guid from messages where bookmark_id = ?', bookmark.id))?.guid;
+
+ await db.run('delete from messages where bookmark_id = ?', bookmark.id);
- const deleteMessage = {
+ return {
'@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
id: `https://${domain}/m/${guid}`,
type: 'Delete',
@@ -136,12 +145,11 @@ async function createDeleteMessage(bookmark, account, domain, db) {
id: `https://${domain}/m/${guid}`,
},
};
-
- return deleteMessage;
}
-export async function createFollowMessage(account, domain, target, db) {
+export async function createFollowMessage(account, domain, target) {
const guid = crypto.randomBytes(16).toString('hex');
+
const followMessage = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: guid,
@@ -150,33 +158,42 @@ export async function createFollowMessage(account, domain, target, db) {
object: target,
};
- db.insertMessage(guid, null, JSON.stringify(followMessage));
+ await db.run(
+ `
+ insert or replace into messages
+ (guid, bookmark_id, message)
+ values (?, ?, ?)
+ `,
+ guid,
+ null,
+ JSON.stringify(followMessage),
+ );
return followMessage;
}
-export async function createUnfollowMessage(account, domain, target, db) {
+export async function createUnfollowMessage(account, domain, target) {
const undoGuid = crypto.randomBytes(16).toString('hex');
- const messageRows = await db.findMessage(target);
+ const messageRows = await db.all('select * from messages where message like ?', `%${target}%`);
console.log('result', messageRows);
- const followMessages = messageRows?.filter((row) => {
+ const followMessage = messageRows.find((row) => {
const message = JSON.parse(row.message || '{}');
return message.type === 'Follow' && message.object === target;
});
- if (followMessages?.length > 0) {
- const undoMessage = {
+ if (followMessage) {
+ return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Undo',
id: undoGuid,
actor: `${domain}/u/${account}`,
- object: followMessages.slice(-1).message,
+ object: followMessage.message,
};
- return undoMessage;
}
+
console.log('tried to find a Follow record in order to unfollow, but failed');
return null;
}
@@ -210,60 +227,61 @@ export async function lookupActorInfo(actorUsername) {
}
}
-export async function broadcastMessage(bookmark, action, db, account, domain) {
- if (actorInfo.disabled) {
- return; // no fediverse setup, so no purpose trying to send messages
- }
+export async function broadcastMessage(bookmark, action, account, domain) {
+ // TODO bail if activitypub not set up
- const result = await db.getFollowers();
- const followers = JSON.parse(result);
+ let followers = (await db.all('select actor from followers')).map((r) => r.actor);
- if (followers === null) {
+ if (!followers.length) {
console.log(`No followers for account ${account}@${domain}`);
- } else {
- const bookmarkPermissions = await db.getPermissionsForBookmark(bookmark.id);
- const globalPermissions = await db.getGlobalPermissions();
- const blocklist =
- bookmarkPermissions?.blocked
- ?.split('\n')
- ?.concat(globalPermissions?.blocked?.split('\n'))
- .filter((x) => !x?.match(/^@([^@]+)@(.+)$/)) || [];
-
- // now let's try to remove the blocked users
- followers.filter((actor) => {
- const matches = blocklist.forEach((username) => {
- actorMatchesUsername(actor, username);
- });
-
- return !matches?.some((x) => x);
+ return;
+ }
+
+ const blocked = await db.all(
+ `
+ select actor
+ from permissions
+ where status = 0
+ and (bookmark_id = 0 or bookmark_id = ?)
+ `,
+ bookmark.id,
+ );
+ const blocklist = blocked.map((b) => b.actor);
+
+ // now let's try to remove the blocked users
+ followers = followers.filter((actor) => {
+ const matches = blocklist.forEach((username) => {
+ actorMatchesUsername(actor, username);
});
- const noteObject = createNoteObject(await bookmark, account, domain);
- let message;
- switch (action) {
- case 'create':
- message = createMessage(noteObject, bookmark.id, account, domain, db);
- break;
- case 'update':
- message = await createUpdateMessage(bookmark, account, domain, db);
- break;
- case 'delete':
- message = await createDeleteMessage(bookmark, account, domain, db);
- break;
- default:
- console.log('unsupported action!');
- return;
- }
+ return !matches?.some((x) => x);
+ });
+
+ const noteObject = createNoteObject(await bookmark, account, domain);
+ let message;
+ switch (action) {
+ case 'create':
+ message = await createMessage(noteObject, bookmark.id, account, domain);
+ break;
+ case 'update':
+ message = await createUpdateMessage(bookmark, account, domain);
+ break;
+ case 'delete':
+ message = await createDeleteMessage(bookmark, account, domain);
+ break;
+ default:
+ console.log('unsupported action!');
+ return;
+ }
- console.log(`sending this message to all followers: ${JSON.stringify(message)}`);
+ console.log(`sending this message to all followers: ${JSON.stringify(message)}`);
- // eslint-disable-next-line no-restricted-syntax
- for (const follower of followers) {
- const inbox = `${follower}/inbox`;
- const myURL = new URL(follower);
- const targetDomain = myURL.host;
- signAndSend(message, account, domain, db, targetDomain, inbox);
- }
+ // eslint-disable-next-line no-restricted-syntax
+ for (const follower of followers) {
+ // TODO: Don't assume that this is where the user's inbox is
+ const inbox = `${follower}/inbox`;
+ const { host: targetDomain } = new URL(follower);
+ signAndSend(message, account, domain, targetDomain, inbox);
}
}
diff --git a/src/bookmarks-db.js b/src/bookmarks-db.js
index 8689b0a..6d90483 100644
--- a/src/bookmarks-db.js
+++ b/src/bookmarks-db.js
@@ -11,9 +11,7 @@ import { open } from 'sqlite';
// unclear why eslint can't resolve this package
// eslint-disable-next-line import/no-unresolved, node/no-missing-import
import { stripHtml } from 'string-strip-html';
-import { timeSince, account, domain } from './util.js';
-
-const ACCOUNT_MENTION_REGEX = new RegExp(`^@${account}@${domain} `);
+import { timeSince, getActorInfo, domain } from './util.js';
// Initialize the database
const dbFile = './.data/bookmarks.db';
@@ -27,10 +25,10 @@ function stripHtmlFromComment(comment) {
return { ...comment, content: stripHtml(comment.content).result };
}
-function stripMentionFromComment(comment) {
+function stripMentionFromComment(account, comment) {
return {
...comment,
- content: comment.content.replace(ACCOUNT_MENTION_REGEX, ''),
+ content: comment.content.replace(new RegExp(`^@${account}@${domain} `), ''),
};
}
@@ -72,8 +70,8 @@ function massageBookmark(bookmark) {
return addBookmarkDomain(addTags(insertRelativeTimestamp(bookmark)));
}
-function massageComment(comment) {
- return generateLinkedDisplayName(stripMentionFromComment(stripHtmlFromComment(insertRelativeTimestamp(comment))));
+function massageComment(account, comment) {
+ return generateLinkedDisplayName(stripMentionFromComment(account, stripHtmlFromComment(insertRelativeTimestamp(comment))));
}
/*
@@ -345,9 +343,11 @@ export async function toggleCommentVisibility(commentId) {
}
export async function getAllCommentsForBookmark(bookmarkId) {
+ const { username: account } = await getActorInfo();
+
try {
const results = await db.all('SELECT * FROM comments WHERE bookmark_id = ?', bookmarkId);
- return results.map((c) => massageComment(c));
+ return results.map((c) => massageComment(account, c));
} catch (dbError) {
console.error(dbError);
}
@@ -355,9 +355,11 @@ export async function getAllCommentsForBookmark(bookmarkId) {
}
export async function getVisibleCommentsForBookmark(bookmarkId) {
+ const { username: account } = await getActorInfo();
+
try {
const results = await db.all('SELECT * FROM comments WHERE visible = 1 AND bookmark_id = ?', bookmarkId);
- return results.map((c) => massageComment(c));
+ return results.map((c) => massageComment(account, c));
} catch (dbError) {
console.error(dbError);
}
diff --git a/src/boot.js b/src/boot.js
new file mode 100644
index 0000000..b1d9ace
--- /dev/null
+++ b/src/boot.js
@@ -0,0 +1,194 @@
+import crypto from 'crypto';
+import * as dotenv from 'dotenv';
+import fs from 'fs';
+import { readFile } from 'fs/promises';
+import { open } from 'sqlite';
+import sqlite3 from 'sqlite3';
+
+import * as db from './database.js';
+import { ACTOR_SETTING_NAMES } from './util.js';
+
+dotenv.config();
+
+const IS_ACTIVITYPUB_DB_IMPORTED = 'isActivitypubDbImported';
+const IS_ACCOUNT_FILE_IMPORTED = 'isAccountFileImported';
+
+// If we don't have public and private keys, generate them
+const generateKeys = async () => {
+ const { PUBLIC_KEY, PRIVATE_KEY } = db;
+
+ const existingKeys = await db.settings.all([PUBLIC_KEY, PRIVATE_KEY]);
+
+ if (existingKeys[PUBLIC_KEY] && existingKeys[PRIVATE_KEY]) {
+ return;
+ }
+
+ await new Promise((resolve, reject) => {
+ crypto.generateKeyPair(
+ 'rsa',
+ {
+ modulusLength: 4096,
+ publicKeyEncoding: {
+ type: 'spki',
+ format: 'pem',
+ },
+ privateKeyEncoding: {
+ type: 'pkcs8',
+ format: 'pem',
+ },
+ },
+ async (err, publicKey, privateKey) => {
+ if (err) {
+ return reject(err);
+ }
+
+ await db.settings.set({
+ [PUBLIC_KEY]: publicKey,
+ [PRIVATE_KEY]: privateKey,
+ });
+
+ return resolve();
+ },
+ );
+ });
+};
+
+// If a legacy activitypub.db database exists and hasn't already been added to
+// application.db, import it and inform the user
+const importActivitypubDb = async () => {
+ const dbFile = './.data/activitypub.db';
+
+ if (!fs.existsSync(dbFile)) {
+ return;
+ }
+
+ const isActivitypubDbImported = await db.settings.get(IS_ACTIVITYPUB_DB_IMPORTED);
+
+ if (isActivitypubDbImported) {
+ console.log('Postmarks detected an activitypub.db file that will no longer be read. You should remove this file.');
+ return;
+ }
+
+ const legacyApDb = await open({ filename: dbFile, driver: sqlite3.Database });
+ const legacyAccount = await legacyApDb.get(`
+ select
+ name,
+ actor as actorJson,
+ privkey as privateKey,
+ pubkey as publicKey,
+ followers,
+ following,
+ blocks
+ from accounts
+ limit 1
+ `);
+
+ // There is theoretically an edge case where there's no account record in
+ // activitypub.db but there are messages and permissions. Let's choose to not
+ // preserve the data in that edge case.
+ if (!legacyAccount) {
+ return;
+ }
+
+ let newSettings = {
+ username: legacyAccount.username,
+ publicKey: legacyAccount.publicKey,
+ privateKey: legacyAccount.privateKey,
+ };
+
+ if (legacyAccount.actorJson) {
+ const actor = JSON.parse(legacyAccount.actorJson);
+ newSettings = {
+ ...newSettings,
+ displayName: actor.name,
+ description: actor.summary,
+ avatar: actor.icon.url,
+ };
+ }
+
+ newSettings = Object.fromEntries(Object.entries(newSettings).filter(([, value]) => Boolean(value)));
+
+ newSettings[IS_ACTIVITYPUB_DB_IMPORTED] = true;
+
+ if (Object.keys(newSettings).length) {
+ await db.settings.set(newSettings);
+ }
+
+ // eslint-disable-next-line no-restricted-syntax
+ for (const key of ['followers', 'following', 'blocks']) {
+ const records = JSON.parse(legacyAccount[key] || '[]').map((actor) => ({ actor }));
+
+ if (records.length) {
+ const [insert, values] = db.buildInsert(records);
+ // eslint-disable-next-line no-await-in-loop
+ await db.run(`insert into ${key} ${insert}`, values);
+ }
+ }
+
+ const messages = await legacyApDb.all('select guid, message, bookmark_id from messages');
+
+ if (messages.length) {
+ const [insert, values] = db.buildInsert(messages);
+ await db.run(`insert into messages ${insert}`, values);
+ }
+
+ const legacyPermissions = await legacyApDb.all('select bookmark_id, allowed, blocked from permissions');
+
+ const permissions = [];
+
+ legacyPermissions.forEach(({ bookmark_id: bookmarkId, allowed, blocked }) => {
+ JSON.parse(allowed).forEach((actor) => {
+ permissions.push({ bookmark_id: bookmarkId, actor, status: 1 });
+ });
+
+ JSON.parse(blocked).forEach((actor) => {
+ permissions.push({ bookmark_id: bookmarkId, actor, status: 0 });
+ });
+ });
+
+ if (permissions.length) {
+ const [insert, values] = db.buildInsert(permissions);
+ await db.run(`insert into permissions ${insert}`, values);
+ }
+
+ await db.settings.set({
+ [IS_ACTIVITYPUB_DB_IMPORTED]: true,
+ });
+
+ console.log('Your activitypub.db file has been imported to the database. You should now remove this file.');
+};
+
+// If a legacy account.json database exists and hasn't already been added to
+// application.db, import it and inform the user
+const importAccountJson = async () => {
+ const jsonPath = new URL('../account.json', import.meta.url);
+
+ if (!fs.existsSync(jsonPath)) {
+ return;
+ }
+
+ const isAccountFileImported = await db.settings.get(IS_ACCOUNT_FILE_IMPORTED);
+
+ if (isAccountFileImported) {
+ console.log('Postmarks detected an account.json file that will no longer be read. You should remove this file.');
+ return;
+ }
+
+ const accountFile = await readFile(jsonPath);
+ const accountFileData = JSON.parse(accountFile);
+
+ await db.settings.set({
+ ...Object.fromEntries(Object.entries(accountFileData).filter(([name]) => ACTOR_SETTING_NAMES.includes(name))),
+ [IS_ACCOUNT_FILE_IMPORTED]: true,
+ });
+
+ console.log('Your account.json file has been imported to the database. You should now remove this file.');
+};
+
+const boot = async () => {
+ await generateKeys();
+ await importActivitypubDb();
+ await importAccountJson();
+};
+
+await boot();
diff --git a/src/database.js b/src/database.js
new file mode 100644
index 0000000..2f294d8
--- /dev/null
+++ b/src/database.js
@@ -0,0 +1,137 @@
+import fs from 'fs';
+import sqlite3 from 'sqlite3';
+
+export const PUBLIC_KEY = 'publicKey';
+export const PRIVATE_KEY = 'privateKey';
+
+const schema = fs.readFileSync('./src/schema.sql').toString();
+const connect = new Promise((resolve, reject) => {
+ const result = new sqlite3.Database('./.data/application.db', (error) => {
+ if (error) {
+ reject(error);
+ } else {
+ result.exec(schema, (execError) => {
+ if (execError) {
+ reject(execError);
+ } else {
+ resolve(result);
+ }
+ });
+ }
+ });
+});
+
+const query =
+ (method) =>
+ async (...args) => {
+ const db = await connect;
+ return new Promise((resolve, reject) => {
+ db[method](...args, (error, result) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(result);
+ }
+ });
+ });
+ };
+
+export const run = query('run');
+export const get = query('get');
+export const all = query('all');
+
+export const settings = {
+ all: async (names) => {
+ // TODO: There must be a way to get node-sqlite3 to accept parameters for an
+ // `IN` clause but I cannot find it. For now let's naïvely assume that every
+ // name matches this pattern. This probably isn't a requirement that's worth
+ // enforcing elsewhere in business logic, and this exact bit of code will
+ // likely lead to weird bugs in the future, but for now it's maybe better to
+ // be safe!
+ if (!names.every((name) => name.match(/^[a-z0-9-_ ./]+$/i))) {
+ throw new Error('Names contain unexpected characters');
+ }
+
+ const rows = await all(`
+ select name, value
+ from settings
+ where name in (${names.map((n) => `'${n}'`).join(',')})
+ `);
+
+ // For the caller of this function, a setting that isn't actually written to
+ // the database should be indistinguishable from a value that is set to
+ // `null`, so we backfill any missing settings to our results.
+ return {
+ ...Object.fromEntries(names.map((name) => [name, null])),
+ ...Object.fromEntries(rows.map(({ name, value }) => [name, JSON.parse(value)])),
+ };
+ },
+ get: (name) => settings.all([name])[0],
+ set: async (obj) => {
+ // TODO: See caveat in settings.all
+ if (!Object.keys(obj).every((name) => name.match(/^[a-z0-9-_ ./]+$/i))) {
+ throw new Error('Names contain unexpected characters');
+ }
+
+ const values = Object.entries(obj).map(
+ ([name, value]) =>
+ // TODO: Escape this properly
+ `('${name}', '${JSON.stringify(value).replace("'", "\\'")}')`,
+ );
+
+ await run(`
+ insert into settings
+ (name, value)
+ values ${values.join(',')}
+ on conflict (name) do update set value = excluded.value
+ `);
+ },
+};
+
+export const getPublicKey = () => settings.get(PUBLIC_KEY);
+export const getPrivateKey = () => settings.get(PRIVATE_KEY);
+
+// Returns an array with two items: a string and an array of values. The string
+// is a fragment of an SQL insert statement that comes after
+// `insert into table_name` and includes columns and placeholder values; the
+// array is a flat list of values that correspond to the placeholders.
+export const buildInsert = (records) => {
+ const recordsArray = records instanceof Array ? records : [records];
+
+ if (!recordsArray.every((r) => typeof r === 'object')) {
+ throw new Error('`records` must be either an object or an array of objects');
+ }
+
+ if (!recordsArray.length) {
+ throw new Error('No records provided');
+ }
+
+ // Get a unique, sorted list of all the keys in all the records
+ const columns = recordsArray
+ .map(Object.keys)
+ .flat()
+ .filter((m, i, a) => a.indexOf(m) === i)
+ .sort();
+
+ const keysTest = columns.join(',');
+
+ if (!recordsArray.every((r) => Object.keys(r).sort().join(',') === keysTest)) {
+ throw new Error('Every object in `records` must contain exactly the same keys');
+ }
+
+ // This check is naïve but should prevent SQL injections
+ columns.forEach((column) => {
+ if (!column.match(/^[a-z][a-z_]*$/)) {
+ throw new Error(`Invalid column name "${column}"`);
+ }
+ });
+
+ // Creates a string like '(?,?,?)' where each `?` corresponds to one string in
+ // `columns`
+ const recordString = `(${new Array(columns.length).fill('?').join(',')})`;
+
+ return [
+ `(${columns.join(',')}) values ${new Array(records.length).fill(recordString).join(',')}`,
+ records.map((r) => columns.map((c) => r[c])).flat(),
+ ];
+};
diff --git a/src/pages/about.hbs b/src/pages/about.hbs
index b6a26d2..db89a27 100644
--- a/src/pages/about.hbs
+++ b/src/pages/about.hbs
@@ -1,4 +1,4 @@
-{{#if actorInfo.disabled}}
+{{#if actorInfo.disabled}}
This is a bookmarking site running on software called Postmarks. It’s diff --git a/src/pages/admin/data.hbs b/src/pages/admin/data.hbs index 42e596b..46c6e33 100644 --- a/src/pages/admin/data.hbs +++ b/src/pages/admin/data.hbs @@ -8,11 +8,13 @@
- It’s less likely you’ll need this, but you can also download your - activitypub.db - here. -
+{{#if hasLegacyActivitypubDb}} ++ It’s less likely you’ll need this, but you can also download your + activitypub.db + here. +
+{{/if}}