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 @@

  • sqlite database
  • csv
  • -

    - 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}}

    diff --git a/src/routes/activitypub/inbox.js b/src/routes/activitypub/inbox.js index 712292e..8803844 100644 --- a/src/routes/activitypub/inbox.js +++ b/src/routes/activitypub/inbox.js @@ -1,7 +1,8 @@ import express from 'express'; import crypto from 'crypto'; import * as linkify from 'linkifyjs'; -import { actorMatchesUsername, parseJSON } from '../../util.js'; +import * as db from '../../database.js'; +import { actorMatchesUsername } from '../../util.js'; import { signAndSend, getInboxFromActorProfile } from '../../activitypub.js'; import { signedGetJSON } from '../../signature.js'; @@ -9,7 +10,6 @@ import { signedGetJSON } from '../../signature.js'; const router = express.Router(); async function sendAcceptMessage(thebody, name, domain, req, res, targetDomain) { - const db = req.app.get('apDb'); const guid = crypto.randomBytes(16).toString('hex'); const message = { '@context': 'https://www.w3.org/ns/activitystreams', @@ -21,130 +21,64 @@ async function sendAcceptMessage(thebody, name, domain, req, res, targetDomain) const inbox = await getInboxFromActorProfile(message.object.actor); - signAndSend(message, name, domain, db, targetDomain, inbox); + signAndSend(message, name, domain, targetDomain, inbox); } async function handleFollowRequest(req, res) { const domain = req.app.get('domain'); - const apDb = req.app.get('apDb'); - const myURL = new URL(req.body.actor); - const targetDomain = myURL.hostname; + const { hostname: targetDomain } = new URL(req.body.actor); const name = req.body.object.replace(`https://${domain}/u/`, ''); await sendAcceptMessage(req.body, name, domain, req, res, targetDomain); - // Add the user to the DB of accounts that follow the account - - // get the followers JSON for the user - const oldFollowersText = (await apDb.getFollowers()) || '[]'; - - // update followers - let followers = parseJSON(oldFollowersText); - if (followers) { - followers.push(req.body.actor); - // unique items - followers = [...new Set(followers)]; - } else { - followers = [req.body.actor]; - } - const newFollowersText = JSON.stringify(followers); - try { - // update into DB - await apDb.setFollowers(newFollowersText); - - console.log('updated followers!'); - } catch (e) { - console.log('error storing followers after follow', e); - } + const { actor } = req.body; + await db.run('insert into followers (actor) values ? on conflict (actor) do nothing', actor); return res.status(200); } async function handleUnfollow(req, res) { const domain = req.app.get('domain'); - const apDb = req.app.get('apDb'); - const myURL = new URL(req.body.actor); const targetDomain = myURL.hostname; const name = req.body.object.object.replace(`https://${domain}/u/`, ''); await sendAcceptMessage(req.body, name, domain, req, res, targetDomain); - // get the followers JSON for the user - const oldFollowersText = (await apDb.getFollowers()) || '[]'; - - // update followers - const followers = parseJSON(oldFollowersText); - if (followers) { - followers.forEach((follower, idx) => { - if (follower === req.body.actor) { - followers.splice(idx, 1); - } - }); - } - - const newFollowersText = JSON.stringify(followers); - - try { - await apDb.setFollowers(newFollowersText); - return res.sendStatus(200); - } catch (e) { - console.log('error storing followers after unfollow', e); - return res.status(500); - } -} - -async function handleFollowAccepted(req, res) { - const apDb = req.app.get('apDb'); - - const oldFollowingText = (await apDb.getFollowing()) || '[]'; - - let follows = parseJSON(oldFollowingText); - - if (follows) { - follows.push(req.body.actor); - // unique items - follows = [...new Set(follows)]; - } else { - follows = [req.body.actor]; - } - const newFollowingText = JSON.stringify(follows); - - try { - // update into DB - await apDb.setFollowing(newFollowingText); - - console.log('updated following!'); - return res.status(200); - } catch (e) { - console.log('error storing follows after follow action', e); - return res.status(500); - } + const { actor } = req.body; + await db.run('delete from followers where actor = ?', actor); + return res.sendStatus(200); } async function handleCommentOnBookmark(req, res, inReplyToGuid) { - const apDb = req.app.get('apDb'); - - const bookmarkId = await apDb.getBookmarkIdFromMessageGuid(inReplyToGuid); + const bookmarkId = (await db.get('select bookmark_id from messages where guid = ?', inReplyToGuid))?.bookmark_id; if (typeof bookmarkId !== 'number') { console.log("couldn't find a bookmark this message is related to"); return res.sendStatus(400); } - const bookmarkPermissions = await apDb.getPermissionsForBookmark(bookmarkId); - const globalPermissions = await apDb.getGlobalPermissions(); - - const bookmarkBlocks = bookmarkPermissions?.blocked?.split('\n') || []; - const globalBlocks = globalPermissions?.blocked?.split('\n') || []; + const permissions = await db.all( + ` + select actor, status + from permissions + where (bookmark_id = 0 or bookmark_id = ?) + `, + bookmarkId, + ); - const bookmarkAllows = bookmarkPermissions?.allowed?.split('\n') || []; - const globalAllows = globalPermissions?.allowed?.split('\n') || []; + const blocklist = []; + const allowlist = []; - const blocklist = bookmarkBlocks.concat(globalBlocks).filter((x) => x.match(/^@([^@]+)@(.+)$/)); - const allowlist = bookmarkAllows.concat(globalAllows).filter((x) => x.match(/^@([^@]+)@(.+)$/)); + permissions.forEach(({ actor, status }) => { + if (status) { + allowlist.push(actor); + } else { + blocklist.push(actor); + } + }); - if (blocklist.length > 0 && blocklist.map((username) => actorMatchesUsername(req.body.actor, username)).some((x) => x)) { + if (blocklist.some((username) => actorMatchesUsername(req.body.actor, username)).some((x) => x)) { console.log(`Actor ${req.body.actor} matches a blocklist item, ignoring comment`); return res.sendStatus(403); } @@ -158,7 +92,7 @@ async function handleCommentOnBookmark(req, res, inReplyToGuid) { const commentUrl = req.body.object.id; let visible = 0; - if (allowlist.map((username) => actorMatchesUsername(req.body.actor, username)).some((x) => x)) { + if (allowlist.some((username) => actorMatchesUsername(req.body.actor, username)).some((x) => x)) { console.log(`Actor ${req.body.actor} matches an allowlist item, marking comment visible`); visible = 1; } @@ -218,7 +152,8 @@ router.post('/', async function (req, res) { return handleUnfollow(req, res); } if (req.body.type === 'Accept' && req.body.object?.type === 'Follow') { - return handleFollowAccepted(req, res); + await db.run('insert into following (actor) values ? on conflict (actor) do nothing', req.body.actor); + return res.status(200); } if (req.body.type === 'Delete') { return handleDeleteRequest(req, res); diff --git a/src/routes/activitypub/message.js b/src/routes/activitypub/message.js index 6ab053e..32a6268 100644 --- a/src/routes/activitypub/message.js +++ b/src/routes/activitypub/message.js @@ -1,5 +1,6 @@ import express from 'express'; import { synthesizeActivity } from '../../activitypub.js'; +import * as db from '../../database.js'; const router = express.Router(); @@ -16,14 +17,12 @@ router.get('/:guid', async (req, res) => { return res.status(400).send('Bad request.'); } - const db = req.app.get('apDb'); - if (!req.headers.accept?.includes('json')) { const bookmarkId = await db.getBookmarkIdFromMessageGuid(guid); return res.redirect(`/bookmark/${bookmarkId}`); } - const result = await db.getMessage(guid); + const result = await db.get('select message from messages where guid = ?', guid); if (result === undefined) { return res.status(404).send(`No message found for ${guid}.`); diff --git a/src/routes/activitypub/user.js b/src/routes/activitypub/user.js index 21f96fc..30457ee 100644 --- a/src/routes/activitypub/user.js +++ b/src/routes/activitypub/user.js @@ -1,56 +1,60 @@ import express from 'express'; +import path from 'path'; import { synthesizeActivity } from '../../activitypub.js'; +import { getActorInfo, domain } from '../../util.js'; +import * as db from '../../database.js'; const router = express.Router(); router.get('/:name', async (req, res) => { - let { name } = req.params; - if (!name) { - return res.status(400).send('Bad request.'); - } + const { name } = req.params; + if (!req.headers.accept?.includes('json')) { return res.redirect('/'); } - const db = req.app.get('apDb'); - const domain = req.app.get('domain'); - const username = name; - name = `${name}@${domain}`; + const { username, avatar, displayName, description, publicKey } = await getActorInfo(); - const actor = await db.getActor(); - - if (actor === undefined) { + if (username !== name) { return res.status(404).send(`No actor record found for ${name}.`); } - const tempActor = JSON.parse(actor); - // Added this followers URI for Pleroma compatibility, see https://github.com/dariusk/rss-to-activitypub/issues/11#issuecomment-471390881 - // New Actors should have this followers URI but in case of migration from an old version this will add it in on the fly - if (tempActor.followers === undefined) { - tempActor.followers = `https://${domain}/u/${username}/followers`; - } - if (tempActor.outbox === undefined) { - tempActor.outbox = `https://${domain}/u/${username}/outbox`; - } - return res.json(tempActor); + + return res.json({ + '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'], + + id: `https://${domain}/u/${username}`, + type: 'Person', + preferredUsername: username, + name: displayName, + summary: description, + icon: { + type: 'Image', + mediaType: `image/${path.extname(avatar).slice(1)}`, + url: avatar, + }, + inbox: `https://${domain}/api/inbox`, + outbox: `https://${domain}/u/${username}/outbox`, + followers: `https://${domain}/u/${username}/followers`, + following: `https://${domain}/u/${username}/following`, + + publicKey: { + id: `https://${domain}/u/${username}#main-key`, + owner: `https://${domain}/u/${username}`, + publicKeyPem: publicKey, + }, + }); }); router.get('/:name/followers', async (req, res) => { const { name } = req.params; + if (!name) { return res.status(400).send('Bad request.'); } - const db = req.app.get('apDb'); - const domain = req.app.get('domain'); - - let followers = await db.getFollowers(); - if (followers === undefined) { - followers = []; - } else { - followers = JSON.parse(followers); - } + const followers = (await db.all('select actor from followers')).map(({ actor }) => actor); - const followersCollection = { + return res.json({ type: 'OrderedCollection', totalItems: followers?.length || 0, id: `https://${domain}/u/${name}/followers`, @@ -62,48 +66,42 @@ router.get('/:name/followers', async (req, res) => { id: `https://${domain}/u/${name}/followers?page=1`, }, '@context': ['https://www.w3.org/ns/activitystreams'], - }; - return res.json(followersCollection); + }); }); router.get('/:name/following', async (req, res) => { const { name } = req.params; + if (!name) { return res.status(400).send('Bad request.'); } - const db = req.app.get('apDb'); - const domain = req.app.get('domain'); - const followingText = (await db.getFollowing()) || '[]'; - const following = JSON.parse(followingText); + const following = (await db.all('select actor from following')).map(({ actor }) => actor); - const followingCollection = { + return res.json({ type: 'OrderedCollection', - totalItems: following?.length || 0, + totalItems: following.length, id: `https://${domain}/u/${name}/following`, first: { type: 'OrderedCollectionPage', - totalItems: following?.length || 0, + totalItems: following.length, partOf: `https://${domain}/u/${name}/following`, orderedItems: following, id: `https://${domain}/u/${name}/following?page=1`, }, '@context': ['https://www.w3.org/ns/activitystreams'], - }; - return res.json(followingCollection); + }); }); router.get('/:name/outbox', async (req, res) => { - const domain = req.app.get('domain'); - const account = req.app.get('account'); - const apDb = req.app.get('apDb'); + const { username: account } = await getActorInfo(); function pageLink(p) { return `https://${domain}/u/${account}/outbox?page=${p}`; } const pageSize = 20; - const totalCount = await apDb.getMessageCount(); + const totalCount = (await db.get('select count(message) as count from messages')).count; const lastPage = Math.ceil(totalCount / pageSize); if (req.query?.page === undefined) { @@ -128,7 +126,17 @@ router.get('/:name/outbox', async (req, res) => { if (page < 1 || page > lastPage) return res.status(400).send('Invalid page number'); const offset = (page - 1) * pageSize; - const notes = await apDb.getMessages(offset, pageSize); + const notes = await db.all( + ` + select message + from messages + order by bookmark_id desc + limit ? + offset ? + `, + pageSize, + offset, + ); const activities = notes.map((n) => synthesizeActivity(JSON.parse(n.message))); const collectionPage = { diff --git a/src/routes/activitypub/webfinger.js b/src/routes/activitypub/webfinger.js index 7ceff24..7237903 100644 --- a/src/routes/activitypub/webfinger.js +++ b/src/routes/activitypub/webfinger.js @@ -1,21 +1,35 @@ import express from 'express'; +import { getActorInfo, domain } from '../../util.js'; + +const ERROR_MESSAGE = 'Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.'; + const router = express.Router(); router.get('/', async (req, res) => { const { resource } = req.query; if (!resource || !resource.includes('acct:')) { - return res.status(400).send('Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.'); + return res.status(400).send(ERROR_MESSAGE); } const name = resource.replace('acct:', ''); - const db = req.app.get('apDb'); - const webfinger = await db.getWebfinger(); - if (webfinger === undefined) { + const { username } = await getActorInfo(); + const actorName = `${username}@${domain}`; + + if (name !== actorName) { return res.status(404).send(`No webfinger record found for ${name}.`); } - return res.json(JSON.parse(webfinger)); + return res.json({ + subject: `acct:${actorName}`, + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: `https://${domain}/u/${username}`, + }, + ], + }); }); export default router; diff --git a/src/routes/admin.js b/src/routes/admin.js index 8847362..f985507 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1,7 +1,9 @@ import express from 'express'; +import fs from 'fs'; // eslint-disable-next-line import/no-unresolved, node/no-missing-import import { stringify as csvStringify } from 'csv-stringify/sync'; // https://github.com/adaltas/node-csv/issues/323 -import { domain, actorInfo, parseJSON } from '../util.js'; +import { domain, getActorInfo } from '../util.js'; +import * as db from '../database.js'; import { isAuthenticated } from '../session-auth.js'; import { lookupActorInfo, createFollowMessage, createUnfollowMessage, signAndSend, getInboxFromActorProfile } from '../activitypub.js'; @@ -40,30 +42,28 @@ router.get('/followers', isAuthenticated, async (req, res) => { params.adminLinks = ADMIN_LINKS; params.currentPath = req.originalUrl; - const apDb = req.app.get('apDb'); - + const { actorInfo } = await getActorInfo(); + // TODO if (actorInfo.disabled) { return res.render('nonfederated', params); } - const permissions = await apDb.getGlobalPermissions(); + params.followers = (await db.all('select actor from followers')).map(({ actor }) => actor); - try { - const followers = await apDb.getFollowers(); - params.followers = JSON.parse(followers || '[]'); - } catch (e) { - console.log('Error fetching followers for admin page'); - } + params.blocks = (await db.all('select actor from blocks')).map(({ actor }) => actor); - try { - const blocks = await apDb.getBlocks(); - params.blocks = JSON.parse(blocks || '[]'); - } catch (e) { - console.log('Error fetching blocks for admin page'); - } + const permissions = await db.get('select actor, status from permissions where bookmark_id = 0'); + + params.blocked = []; + params.allowed = []; - params.allowed = permissions?.allowed || ''; - params.blocked = permissions?.blocked || ''; + permissions.forEach(({ actor, status }) => { + if (status) { + params.blocked.push(actor); + } else { + params.allowed.push(actor); + } + }); return res.render('admin/followers', params); }); @@ -73,18 +73,14 @@ router.get('/following', isAuthenticated, async (req, res) => { params.adminLinks = ADMIN_LINKS; params.currentPath = req.originalUrl; - const apDb = req.app.get('apDb'); + const { actorInfo } = await getActorInfo(); + // TODO if (actorInfo.disabled) { return res.render('nonfederated', params); } - try { - const following = await apDb.getFollowing(); - params.following = JSON.parse(following || '[]'); - } catch (e) { - console.log('Error fetching followers for admin page'); - } + params.following = (await db.all('select actor from following')).map(({ actor }) => actor); return res.render('admin/following', params); }); @@ -93,6 +89,7 @@ router.get('/data', isAuthenticated, async (req, res) => { const params = req.query.raw ? {} : { title: 'Data export' }; params.adminLinks = ADMIN_LINKS; params.currentPath = req.originalUrl; + params.hasLegacyActivitypubDb = fs.existsSync(`${DATA_PATH}/activitypub.db`); return res.render('admin/data', params); }); @@ -120,6 +117,11 @@ router.get('/bookmarks.csv', isAuthenticated, async (req, res) => { router.get('/activitypub.db', isAuthenticated, async (req, res) => { const filePath = `${DATA_PATH}/activitypub.db`; + if (!fs.existsSync(filePath)) { + res.status(404).send('This Postmarks instance does not include a legacy actiivtypub.db file.'); + return; + } + res.setHeader('Content-Type', 'application/vnd.sqlite3'); res.setHeader('Content-Disposition', 'attachment; filename="activitypub.db"'); @@ -127,80 +129,26 @@ router.get('/activitypub.db', isAuthenticated, async (req, res) => { }); router.post('/followers/block', isAuthenticated, async (req, res) => { - const db = req.app.get('apDb'); - - const oldFollowersText = (await db.getFollowers()) || '[]'; - - // update followers - const followers = parseJSON(oldFollowersText); - if (followers) { - followers.forEach((follower, idx) => { - if (follower === req.body.actor) { - followers.splice(idx, 1); - } - }); - } + const { actor } = req.body; - const newFollowersText = JSON.stringify(followers); - - try { - await db.setFollowers(newFollowersText); - } catch (e) { - console.log('error storing followers after unfollow', e); + if (!actor) { + return res.status(400).send('No actor specified'); } - const oldBlocksText = (await db.getBlocks()) || '[]'; + await db.run('delete from followers where actor = ?', actor); - let blocks = parseJSON(oldBlocksText); - - if (blocks) { - blocks.push(req.body.actor); - // unique items - blocks = [...new Set(blocks)]; - } else { - blocks = [req.body.actor]; - } - const newBlocksText = JSON.stringify(blocks); - try { - // update into DB - await db.setBlocks(newBlocksText); - - console.log('updated blocks!'); - } catch (e) { - console.log('error storing blocks after block action', e); - } + await db.run('insert into blocks (actor) values ? on conflict (actor) do nothing', actor); - res.redirect('/admin/followers'); + return res.redirect('/admin/followers'); }); router.post('/followers/unblock', isAuthenticated, async (req, res) => { - const db = req.app.get('apDb'); - - const oldBlocksText = (await db.getBlocks()) || '[]'; - - const blocks = parseJSON(oldBlocksText); - if (blocks) { - blocks.forEach((block, idx) => { - if (block === req.body.actor) { - blocks.splice(idx, 1); - } - }); - } - - const newBlocksText = JSON.stringify(blocks); - - try { - await db.setBlocks(newBlocksText); - } catch (e) { - console.log('error storing blocks after unblock action', e); - } - + await db.run('delete from blocks where actor = ?', req.body.actor); res.redirect('/admin/followers'); }); router.post('/following/follow', isAuthenticated, async (req, res) => { - const db = req.app.get('apDb'); - const account = req.app.get('account'); + const { username: account } = await getActorInfo(); const canonicalUrl = await lookupActorInfo(req.body.actor); @@ -208,8 +156,8 @@ router.post('/following/follow', isAuthenticated, async (req, res) => { const inbox = await getInboxFromActorProfile(canonicalUrl); if (inbox) { - const followMessage = await createFollowMessage(account, domain, canonicalUrl, db); - signAndSend(followMessage, account, domain, db, req.body.actor.split('@').slice(-1), inbox); + const followMessage = await createFollowMessage(account, domain, canonicalUrl); + signAndSend(followMessage, account, domain, req.body.actor.split('@').slice(-1), inbox); } return res.redirect('/admin/following'); @@ -220,49 +168,42 @@ router.post('/following/follow', isAuthenticated, async (req, res) => { }); router.post('/following/unfollow', isAuthenticated, async (req, res) => { - const db = req.app.get('apDb'); - const account = req.app.get('account'); - - const oldFollowsText = (await db.getFollowing()) || '[]'; - - const follows = parseJSON(oldFollowsText); - if (follows) { - follows.forEach((follow, idx) => { - if (follow === req.body.actor) { - follows.splice(idx, 1); - } - }); - - const inbox = await getInboxFromActorProfile(req.body.actor); - - const unfollowMessage = createUnfollowMessage(account, domain, req.body.actor, db); - - signAndSend(unfollowMessage, account, domain, db, new URL(req.body.actor).hostname, inbox); - - const newFollowsText = JSON.stringify(follows); - - try { - await db.setFollowing(newFollowsText); - } catch (e) { - console.log('error storing follows after unfollow action', e); - } - return res.redirect('/admin/following'); - } - return res.status(500).send('Encountered an error processing existing following list'); + const { username: account } = await getActorInfo(); + const { actor } = req.body; + + await db.run('delete from followers where actor = ?', actor); + const inbox = await getInboxFromActorProfile(actor); + const unfollowMessage = createUnfollowMessage(account, domain, actor); + signAndSend(unfollowMessage, account, domain, new URL(actor).hostname, inbox); + return res.redirect('/admin/following'); }); router.post('/permissions', isAuthenticated, async (req, res) => { - const apDb = req.app.get('apDb'); - - await apDb.setGlobalPermissions(req.body.allowed, req.body.blocked); + const { allowed, blocked } = req.body; + + const records = JSON.parse(allowed) + .map((actor) => ({ bookmark_id: 0, actor, status: 1 })) + .concat(JSON.parse(blocked).map((actor) => ({ bookmark_id: 0, actor, status: 0 }))); + + if (records.length) { + const [insert, values] = db.buildInsert(records); + + await db.run( + ` + insert into permissions + ${insert} + on conflict (bookmark_id, actor) do update set status = excluded.status + `, + values, + ); + } res.redirect('/admin'); }); router.post('/reset', isAuthenticated, async (req, res) => { - const db = req.app.get('bookmarksDb'); - - await db.deleteAllBookmarks(); + const bookmarksDb = req.app.get('bookmarksDb'); + await bookmarksDb.deleteAllBookmarks(); res.redirect('/admin'); }); diff --git a/src/routes/bookmark.js b/src/routes/bookmark.js index d6ec94f..f22c66f 100644 --- a/src/routes/bookmark.js +++ b/src/routes/bookmark.js @@ -1,9 +1,10 @@ import express from 'express'; import ogScraper from 'open-graph-scraper'; -import { data, account, domain, removeEmpty } from '../util.js'; +import { data, getActorInfo, domain, removeEmpty } from '../util.js'; import { broadcastMessage } from '../activitypub.js'; import { isAuthenticated } from '../session-auth.js'; +import * as db from '../database.js'; const router = express.Router(); export default router; @@ -100,7 +101,6 @@ router.get('/:id', async (req, res) => { router.get('/:id/edit', isAuthenticated, async (req, res) => { const params = req.query.raw ? {} : { ephemeral: false }; const bookmarksDb = req.app.get('bookmarksDb'); - const apDb = req.app.get('apDb'); const bookmark = await bookmarksDb.getBookmark(req.params.id); bookmark.tagsArray = encodeURIComponent(JSON.stringify(bookmark.tags?.split(' ').map((b) => b.slice(1)) || [])); @@ -109,9 +109,18 @@ router.get('/:id/edit', isAuthenticated, async (req, res) => { if (!bookmark) { params.error = data.errorMessage; } else { - const permissions = await apDb.getPermissionsForBookmark(req.params.id); - params.allowed = permissions?.allowed; - params.blocked = permissions?.blocked; + params.allowed = []; + params.blocked = []; + + const permissions = await db.all('select * from permissions where bookmark_id = ?', req.params.id); + + permissions.forEach(({ actor, status }) => { + if (status) { + params.allowed.push(actor); + } else { + params.blocked.push(actor); + } + }); params.title = 'Edit Bookmark'; params.bookmark = bookmark; @@ -125,11 +134,12 @@ router.post('/:id/delete', isAuthenticated, async (req, res) => { const params = {}; const { id } = req.params; const bookmarksDb = req.app.get('bookmarksDb'); - const apDb = req.app.get('apDb'); await bookmarksDb.deleteBookmark(id); - broadcastMessage({ id }, 'delete', apDb, account, domain); + const { username: account } = await getActorInfo(); + + await broadcastMessage({ id }, 'delete', account, domain); return req.query.raw ? res.send(params) : res.redirect('/'); }); @@ -181,7 +191,6 @@ router.post('/multiadd', isAuthenticated, async (req, res) => { router.post('/:id?', isAuthenticated, async (req, res) => { const bookmarksDb = req.app.get('bookmarksDb'); - const apDb = req.app.get('apDb'); const params = {}; const { id } = req.params; @@ -221,9 +230,30 @@ router.post('/:id?', isAuthenticated, async (req, res) => { description: req.body.description.trim(), tags, }); - await apDb.setPermissionsForBookmark(id, req.body.allowed || '', req.body.blocked || ''); - broadcastMessage(bookmark, 'update', apDb, account, domain); + const { allowed, blocked } = req.body; + + const records = JSON.parse(allowed) + .map((actor) => ({ bookmark_id: id, actor, status: 1 })) + .concat(JSON.parse(blocked).map((actor) => ({ bookmark_id: id, actor, status: 0 }))); + + if (records.length) { + const [insert, values] = db.buildInsert(records); + + await db.run( + ` + insert into permissions + ${insert} + on conflict (bookmark_id, actor) + do update set status = excluded.status + `, + values, + ); + } + + const { username: account } = await getActorInfo(); + + await broadcastMessage(bookmark, 'update', account, domain); } } else { const noTitle = req.body.title === ''; @@ -253,7 +283,9 @@ router.post('/:id?', isAuthenticated, async (req, res) => { tags, }); - broadcastMessage(bookmark, 'create', apDb, account, domain); + const { username: account } = await getActorInfo(); + + await broadcastMessage(bookmark, 'create', account, domain); } params.bookmarks = bookmark; diff --git a/src/routes/core.js b/src/routes/core.js index ecb2c20..81e8379 100644 --- a/src/routes/core.js +++ b/src/routes/core.js @@ -1,6 +1,6 @@ import express from 'express'; import * as linkify from 'linkifyjs'; -import { data, actorInfo } from '../util.js'; +import { data, getActorInfo } from '../util.js'; import { isAuthenticated } from '../session-auth.js'; const router = express.Router(); @@ -53,6 +53,8 @@ router.get('/', async (req, res) => { }); router.get('/about', async (req, res) => { + const actorInfo = await getActorInfo(); + res.render('about', { title: 'About', actorInfo, @@ -97,7 +99,9 @@ router.get('/index.xml', async (req, res) => { params.last_updated = lastUpdated.toISOString(); } - params.feedTitle = req.app.get('site_name'); + const { displayName, username: account } = await getActorInfo(); + params.account = account; + params.feedTitle = displayName; params.layout = false; res.type('application/atom+xml'); @@ -124,9 +128,13 @@ router.get('/tagged/*.xml', async (req, res) => { params.last_updated = bookmarks[0].created_at; } - params.feedTitle = `${req.app.get('site_name')}: Bookmarks tagged '${tags.join(' and ')}'`; + const { displayName, username: account } = await getActorInfo(); + + params.feedTitle = `${displayName}: Bookmarks tagged '${tags.join(' and ')}'`; params.layout = false; + params.account = account; + res.type('application/atom+xml'); return res.render('bookmarks-xml', params); }); diff --git a/src/schema.sql b/src/schema.sql new file mode 100644 index 0000000..80f99c8 --- /dev/null +++ b/src/schema.sql @@ -0,0 +1,40 @@ +create table if not exists settings ( + name text unique not null check (name <> ""), + value text not null +); + +insert into settings (name, value) +values + ('username', '"bookmarks"'), + ('avatar', '"https://cdn.glitch.global/8eaf209c-2fa9-4353-9b99-e8d8f3a5f8d4/postmarks-logo-white-small.png?v=1693610556689"'), + ('displayName', '"Postmarks"'), + ('description', '"An ActivityPub bookmarking and sharing site built with Postmarks"') +on conflict (name) do nothing; + +create table if not exists followers ( + actor text primary key +); + +create table if not exists following ( + actor text primary key +); + +create table if not exists blocks ( + actor text primary key +); + +-- TODO: index messages on bookmark_id +create table if not exists messages ( + guid text primary key, + message text, + bookmark_id integer +); + +-- TODO add index and unique constraint on (bookmark_id, actor) +create table if not exists permissions ( + bookmark_id integer not null, + actor text not null default '', + -- 0 = blocked + -- 1 = allowed + status integer not null +); diff --git a/src/signature.js b/src/signature.js index 473d10d..c515b57 100644 --- a/src/signature.js +++ b/src/signature.js @@ -1,8 +1,8 @@ import crypto from 'crypto'; import fetch from 'node-fetch'; -import { account, domain } from './util.js'; -import { getPrivateKey } from './activity-pub-db.js'; +import { getActorInfo, domain } from './util.js'; +import { getPrivateKey } from './database.js'; /** * Returns base-64 encoded SHA-256 digest of provided data @@ -18,16 +18,16 @@ function getDigest(data) { /** * Returns base-64 encoded string signed with user's RSA private key * - * @param {string} privkey - Postmarks user's private key + * @param {string} privateKey - Postmarks user's private key * @param {string} data - UTF-8 string to sign * * @returns {string} */ -function getSignature(privkey, data) { +function getSignature(privateKey, data) { const signer = crypto.createSign('sha256'); signer.update(data); signer.end(); - return signer.sign(privkey).toString('base64'); + return signer.sign(privateKey).toString('base64'); } /** @@ -67,12 +67,13 @@ function getSignatureParams(body, method, url) { /** * Returns the full "Signature" header to be included in the signed request * + * @param {string} account - The actor's username * @param {string} signature - Base-64 encoded request signature * @param {string[]} signatureKeys - Array of param names used when generating the signature * * @returns {string} */ -function getSignatureHeader(signature, signatureKeys) { +function getSignatureHeader(account, signature, signatureKeys) { return [ `keyId="https://${domain}/u/${account}"`, `algorithm="rsa-sha256"`, @@ -90,11 +91,9 @@ function getSignatureHeader(signature, signatureKeys) { * @returns {Promise} */ export async function signedFetch(url, init = {}) { - const privkey = await getPrivateKey(`${account}@${domain}`); - if (!privkey) { - throw new Error(`No private key found for ${account}.`); - } + const { username: account } = await getActorInfo(); + const privateKey = await getPrivateKey(); const { headers = {}, body = null, method = 'GET', ...rest } = init; const signatureParams = getSignatureParams(body, method, url); @@ -102,8 +101,8 @@ export async function signedFetch(url, init = {}) { const stringToSign = Object.entries(signatureParams) .map(([k, v]) => `${k}: ${v}`) .join('\n'); - const signature = getSignature(privkey, stringToSign); - const signatureHeader = getSignatureHeader(signature, signatureKeys); + const signature = getSignature(privateKey, stringToSign); + const signatureHeader = getSignatureHeader(account, signature, signatureKeys); return fetch(url, { body, diff --git a/src/util.js b/src/util.js index 0a41a4d..b899dbc 100644 --- a/src/util.js +++ b/src/util.js @@ -1,27 +1,16 @@ +import chalk from 'chalk'; import fs from 'fs'; import { readFile } from 'fs/promises'; -import chalk from 'chalk'; -import * as dotenv from 'dotenv'; +import * as db from './database.js'; -dotenv.config(); +export const ACTOR_SETTING_NAMES = ['username', 'avatar', 'displayName', 'description', 'publicKey']; export const data = { errorMessage: 'Whoops! Error connecting to the database–please try again!', setupMessage: "🚧 Whoops! Looks like the database isn't setup yet! 🚧", }; -let actorFileData = {}; -try { - const accountFile = await readFile(new URL('../account.json', import.meta.url)); - actorFileData = JSON.parse(accountFile); - actorFileData.disabled = false; -} catch (e) { - console.log('no account.json file found, assuming non-fediverse mode for now. restart the app to check again'); - actorFileData = { disabled: true }; -} - -export const actorInfo = actorFileData; -export const account = actorInfo.username || 'bookmarks'; +export const getActorInfo = () => db.settings.all(ACTOR_SETTING_NAMES); export const domain = (() => { if (process.env.PUBLIC_BASE_URL) {