Compare commits
258 Commits
master
...
459-activi
Author | SHA1 | Date |
---|---|---|
Bryan Ashby | 19b6f93be4 | |
Bryan Ashby | 2bf4540707 | |
Bryan Ashby | de4e3a1296 | |
Bryan Ashby | cc42812cea | |
Bryan Ashby | fcd961c854 | |
Bryan Ashby | 65ea0192fa | |
Bryan Ashby | 460070e61d | |
Bryan Ashby | d5ab53ecad | |
Bryan Ashby | ea6ab5a146 | |
Bryan Ashby | 6ea3e504e5 | |
Bryan Ashby | 28832e77f3 | |
Bryan Ashby | 7961fa48db | |
Bryan Ashby | 2f9871e005 | |
Bryan Ashby | c25c600417 | |
Bryan Ashby | 4d97922933 | |
Bryan Ashby | 0035ef4f39 | |
Bryan Ashby | 2964c01841 | |
Bryan Ashby | 42b1b65cdc | |
Bryan Ashby | 4bd55f1554 | |
Bryan Ashby | cded6a59b5 | |
Bryan Ashby | d5a7905225 | |
Bryan Ashby | 4501140a15 | |
Bryan Ashby | d5bffb1719 | |
Bryan Ashby | 0858916490 | |
Bryan Ashby | a5a3a63b00 | |
Bryan Ashby | 0c1785c462 | |
Bryan Ashby | 60b17a64ae | |
Bryan Ashby | a5a72d8270 | |
Bryan Ashby | 31f10d0c78 | |
Bryan Ashby | dd8ab851f2 | |
Bryan Ashby | fa95616933 | |
Bryan Ashby | ed7a728bd7 | |
Bryan Ashby | 6dde005f21 | |
Bryan Ashby | e5fdc2450c | |
Bryan Ashby | 821d6cef70 | |
Bryan Ashby | beb28c9696 | |
Bryan Ashby | 95c249fc23 | |
Bryan Ashby | db3387d6c5 | |
Bryan Ashby | 052c2d5a9b | |
Bryan Ashby | 32bb0c4937 | |
Bryan Ashby | 33dca44286 | |
Bryan Ashby | 71076c8e90 | |
Bryan Ashby | 22e7689f01 | |
Bryan Ashby | faf8ccaaf8 | |
Bryan Ashby | ea3d2cffec | |
Bryan Ashby | 1c27891f15 | |
Bryan Ashby | 999a87c7ef | |
Bryan Ashby | c4553a01a5 | |
Bryan Ashby | 7499ffb56b | |
Bryan Ashby | 84c6478849 | |
Bryan Ashby | 5e4c94210a | |
Bryan Ashby | d035fc5245 | |
Bryan Ashby | 0b59d0e3b5 | |
Bryan Ashby | 97f3a1e63a | |
Bryan Ashby | cc20ffc85f | |
Bryan Ashby | 127d09a09b | |
Bryan Ashby | 35c97f7035 | |
Bryan Ashby | 94842afd7e | |
Bryan Ashby | 81edff48fe | |
Bryan Ashby | cca850dd78 | |
Bryan Ashby | 26c44b91a6 | |
Bryan Ashby | fb02fc599a | |
Bryan Ashby | e915527427 | |
Bryan Ashby | 8c609b79bb | |
Bryan Ashby | 10e2abffc6 | |
Bryan Ashby | 5314fb4ad9 | |
Bryan Ashby | 8b6d564ebf | |
Bryan Ashby | 3212d809df | |
Bryan Ashby | ea9d826a7c | |
Bryan Ashby | 6afbb29139 | |
Bryan Ashby | b0fff20a02 | |
Bryan Ashby | 65e5fa1b77 | |
Bryan Ashby | a968f21957 | |
Bryan Ashby | 63cfc904aa | |
Bryan Ashby | ec68f8c80c | |
Bryan Ashby | 0af70b0f57 | |
Bryan Ashby | 0263d8bc5e | |
Bryan Ashby | a205445dd1 | |
Bryan Ashby | 5b08d21966 | |
Bryan Ashby | 60e8b787d9 | |
Nathan Byrd | 24645c98b0 | |
Bryan Ashby | 0b1794c210 | |
Bryan Ashby | 22349a23ec | |
Bryan Ashby | 86d2aeb9de | |
Bryan Ashby | 8822a7cc3d | |
Nathan Byrd | 43cdaf0c5d | |
Bryan Ashby | ccd229d7c6 | |
Bryan Ashby | f264e4886e | |
Bryan Ashby | 2495430fae | |
Bryan Ashby | e35fc5bf41 | |
Bryan Ashby | 6e53c25d99 | |
Bryan Ashby | ad1ab1f0c5 | |
Bryan Ashby | c9f9eb9e17 | |
Bryan Ashby | 40e07d7d84 | |
Bryan Ashby | 777df9f879 | |
Bryan Ashby | 30cb21f092 | |
Bryan Ashby | 036a3dcd58 | |
Nathan Byrd | 8668eedce2 | |
Bryan Ashby | 51c58b5d8a | |
Nathan Byrd | 3d1ac922dc | |
Nathan Byrd | c79bb1e99f | |
Nathan Byrd | 7a189b238f | |
Nathan Byrd | e963f18ba4 | |
Bryan Ashby | 53eff2715a | |
Bryan Ashby | 62735411f6 | |
Bryan Ashby | 560d608cd2 | |
Bryan Ashby | e8c42a9b2e | |
Bryan Ashby | 2aec375bee | |
Bryan Ashby | 69cb0c5907 | |
Bryan Ashby | e90f42c53c | |
Bryan Ashby | 194a5b012e | |
Nathan Byrd | b577c1b847 | |
Bryan Ashby | df55c3fa6d | |
Bryan Ashby | a09fe4894f | |
Nathan Byrd | 6466220b6d | |
Nathan Byrd | 87034967ae | |
Bryan Ashby | 1fcadef8d0 | |
Bryan Ashby | c0914af002 | |
Bryan Ashby | fb039c1abc | |
Bryan Ashby | 9b08cf827b | |
Bryan Ashby | c5f0e0e6ef | |
Bryan Ashby | 39a49f00be | |
Bryan Ashby | c9b3c9bc41 | |
Bryan Ashby | 1b684e2f2b | |
Bryan Ashby | 926f45b917 | |
Bryan Ashby | 834dfd693f | |
Bryan Ashby | 27da2bb108 | |
Bryan Ashby | 0402de7444 | |
Bryan Ashby | 36ebda5269 | |
Bryan Ashby | 41cd0f7f33 | |
Bryan Ashby | bd2dc27477 | |
Bryan Ashby | 24de0fa0bf | |
Bryan Ashby | 99ae973396 | |
Nathan Byrd | 77b0e6dd23 | |
Nathan Byrd | 877e2ca61a | |
Bryan Ashby | c3335ce062 | |
Bryan Ashby | a24ec5fd67 | |
Bryan Ashby | 21fb688bf6 | |
Bryan Ashby | b20b6cc5ca | |
Bryan Ashby | a8e867a4bb | |
Bryan Ashby | 1065f14c2e | |
Nathan Byrd | 00e6b41a3e | |
Bryan Ashby | f97d1844e3 | |
Nathan Byrd | 95250d23f2 | |
Bryan Ashby | 5cfacf4ff0 | |
Bryan Ashby | 8a4f90263a | |
Bryan Ashby | d286fa2cf4 | |
Bryan Ashby | b94fa6addd | |
Bryan Ashby | 5f53ef9a60 | |
Bryan Ashby | eb9d9055e9 | |
Nathan Byrd | 3be14ec94b | |
Nathan Byrd | 1d1bf68f0d | |
Bryan Ashby | 45deef3f03 | |
Bryan Ashby | 835bfbddb0 | |
Bryan Ashby | f8d4f49f7f | |
Bryan Ashby | 98d37e9564 | |
Bryan Ashby | a829905c63 | |
Bryan Ashby | c456c18b85 | |
Bryan Ashby | 35b7c00d11 | |
Bryan Ashby | 3bdce81bdb | |
Bryan Ashby | 2a75d55b42 | |
Bryan Ashby | 0ca67f6729 | |
Bryan Ashby | 6dd9fe810f | |
Bryan Ashby | 2f577fcada | |
Bryan Ashby | 9b01124b2e | |
Nathan Byrd | 4f6891a668 | |
Nathan Byrd | 8dd28e3091 | |
Bryan Ashby | d624871a83 | |
Bryan Ashby | 0bd2c3db1c | |
Bryan Ashby | 4f632fd8c4 | |
Bryan Ashby | 82091c11c1 | |
Bryan Ashby | 1aa56fbaa7 | |
Nathan Byrd | 5b69cdb516 | |
Nathan Byrd | f8b132310c | |
Bryan Ashby | d5446cdb51 | |
Bryan Ashby | d7df066ab0 | |
Bryan Ashby | 0fc8ae0e18 | |
Bryan Ashby | 8f131630ff | |
Bryan Ashby | d03718d55e | |
Bryan Ashby | 3409c99f2d | |
Bryan Ashby | c5fb1bd685 | |
Bryan Ashby | 3f2dcee5a7 | |
Bryan Ashby | 468f1486c0 | |
Bryan Ashby | ce7dd8e1cd | |
Bryan Ashby | d9e4b66a35 | |
Bryan Ashby | 9517b292a4 | |
Bryan Ashby | 930308e07f | |
Bryan Ashby | 51308a5ad3 | |
Bryan Ashby | b075e25330 | |
Nathan Byrd | 4841823d67 | |
Nathan Byrd | 3cb4f5158e | |
Bryan Ashby | 315d77b1c0 | |
Nathan Byrd | c796a856b1 | |
Nathan Byrd | 84dde6c5c5 | |
Nathan Byrd | 1068abca80 | |
Nathan Byrd | af1f8890f6 | |
Bryan Ashby | 9ad0cabd04 | |
Nathan Byrd | 02eeee95ac | |
Nathan Byrd | 4137e935d2 | |
Nathan Byrd | 33ea963a14 | |
Bryan Ashby | 5e5c9236ec | |
Bryan Ashby | 157b90687c | |
Bryan Ashby | 67652b18f2 | |
Bryan Ashby | fc14b5d299 | |
Bryan Ashby | eaadd0a830 | |
Bryan Ashby | 64848b4675 | |
Bryan Ashby | ef118325ba | |
Bryan Ashby | 01cd91b045 | |
Bryan Ashby | 44c67f5327 | |
Bryan Ashby | 3a70cc6939 | |
Bryan Ashby | e5b2beffcf | |
Bryan Ashby | 2370185bcc | |
Bryan Ashby | f86d9338a1 | |
Bryan Ashby | 416f86a0cc | |
Bryan Ashby | f9f9208ada | |
Bryan Ashby | 55b210e4e7 | |
Nathan Byrd | 2c0992becb | |
Nathan Byrd | 9eb3a1d37f | |
Bryan Ashby | 23f753e4b3 | |
Bryan Ashby | a1e54dee6d | |
Bryan Ashby | 60238de017 | |
Nathan Byrd | b252f69f05 | |
Bryan Ashby | d278307a81 | |
Bryan Ashby | 41867c73d5 | |
Bryan Ashby | 7380ef571a | |
Bryan Ashby | 848044bec6 | |
Nathan Byrd | d615b53f1f | |
Nathan Byrd | e478665456 | |
Nathan Byrd | 344d4716ce | |
Nathan Byrd | 9f33c8b21d | |
Bryan Ashby | dc7f902182 | |
Bryan Ashby | 8026164ae4 | |
Bryan Ashby | 5055337eff | |
Bryan Ashby | d4f74447ec | |
Bryan Ashby | 6cea4269b2 | |
Bryan Ashby | 127e9794ee | |
Bryan Ashby | 99e9ebbec9 | |
Bryan Ashby | fb5858e90f | |
Bryan Ashby | 092acc0138 | |
Nathan Byrd | bc0f7690f8 | |
Nathan Byrd | 395676f19d | |
Bryan Ashby | 25e3630458 | |
Bryan Ashby | ff219cbb06 | |
Bryan Ashby | 2b958e0885 | |
Bryan Ashby | 380920f6c8 | |
Nathan Byrd | db652bff59 | |
Nathan Byrd | e1b4c3e510 | |
Bryan Ashby | 38098b46f1 | |
Bryan Ashby | a00a93859e | |
Bryan Ashby | 0e32e3856e | |
Bryan Ashby | e78f9cdb71 | |
Bryan Ashby | 7b5cb165ee | |
Nathan Byrd | fb035f2b58 | |
Bryan Ashby | 3db35bc5b6 | |
Nathan Byrd | d8de6171aa | |
Bryan Ashby | 46bc92a690 | |
Bryan Ashby | b1bb66e52f | |
Bryan Ashby | d2d5aad236 |
|
@ -18,7 +18,15 @@
|
|||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["ms-azuretools.vscode-docker","alexcvzz.vscode-sqlite","yzhang.markdown-all-in-one", "DavidAnson.vscode-markdownlint", "christian-kohler.npm-intellisense", "dbaeumer.vscode-eslint", "bierner.markdown-yaml-preamble"]
|
||||
"extensions": [
|
||||
"ms-azuretools.vscode-docker",
|
||||
"alexcvzz.vscode-sqlite",
|
||||
"yzhang.markdown-all-in-one",
|
||||
"DavidAnson.vscode-markdownlint",
|
||||
"christian-kohler.npm-intellisense",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bierner.markdown-yaml-preamble"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "prettier"],
|
||||
"extends": ["eslint:recommended", "plugin:json/recommended"],
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
|
@ -16,7 +16,8 @@
|
|||
"quotes": ["error", "single"],
|
||||
"semi": ["error", "always"],
|
||||
"comma-dangle": 0,
|
||||
"no-trailing-spaces": "error"
|
||||
"no-trailing-spaces": "error",
|
||||
"no-control-regex": 0
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"*.js": ["npx eslint --fix", "npx prettier --write"],
|
||||
"*.json": ["npx eslint --fix", "npx prettier --write"]
|
||||
}
|
|
@ -1,6 +1,3 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"laktak.hjson"
|
||||
]
|
||||
"recommendations": ["ms-vscode-remote.remote-containers", "laktak.hjson"]
|
||||
}
|
|
@ -2,6 +2,30 @@
|
|||
|
||||
## Style & Formatting
|
||||
* In general, [Prettier](https://prettier.io) is used. See the [Prettier installation and basic instructions](https://prettier.io/docs/en/install.html) for more information.
|
||||
* Though you'll see a lot of older style callback code, please utilize modern JavaScript. ES6 classes, arrow functions, and builtins.
|
||||
* Though you'll see a lot of older style callback code, please utilize modern JavaScript. ES6 classes, [Arrow Functions](#arrow-functions), and builtins.
|
||||
* There is almost never a reason to use `var`. Prefer `const` where you can and and `let` otherwise.
|
||||
* Save with UNIX line feeds, UTF-8 without BOM, and tabs set to 4 spaces.
|
||||
* Do not include the `.js` suffix when [Importing (require)](#import-require)
|
||||
|
||||
### Arrow Functions
|
||||
Prefer anonymous arrow functions with access to `this` for callbacks.
|
||||
```js
|
||||
// Good!
|
||||
someApi(foo, bar, (err, result) => {
|
||||
// ...
|
||||
});
|
||||
|
||||
// Bad :(
|
||||
someApi(foo, bar, function callback(err, result) {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Import (require)
|
||||
```javascript
|
||||
// Good!
|
||||
const foo = require('foo');
|
||||
|
||||
// Bad :(
|
||||
const foo = require('foo.js');
|
||||
```
|
|
@ -1,4 +1,4 @@
|
|||
Copyright (c) 2015-2022, Bryan D. Ashby
|
||||
Copyright (c) 2015-2023, Bryan D. Ashby
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
|
|
|
@ -92,7 +92,7 @@ ENiGMA has been tested with many terminals. However, the following are suggested
|
|||
## License
|
||||
Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license:
|
||||
|
||||
Copyright (c) 2015-2022, Bryan D. Ashby
|
||||
Copyright (c) 2015-2023, Bryan D. Ashby
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
|
|
|
@ -28,4 +28,4 @@ env CC=gcc CXX=gcc npm rebuild --build-from-source node-pty
|
|||
### Missing Menu & Theme Entries
|
||||
One thing to be sure and check after an update is your menu/prompt HJSON configurations as well as your theme(s). The default templates are updated alongside features, but you may need to merge in fragments missing from your own.
|
||||
|
||||
See also [Updating](./docs/_docs/admin/updating.md)
|
||||
See also [Upgrading](./docs/_docs/admin/upgrading.md)
|
56
UPGRADE.md
56
UPGRADE.md
|
@ -1,39 +1,49 @@
|
|||
# Introduction
|
||||
This document covers basic upgrade notes for major ENiGMA½ version updates.
|
||||
|
||||
> :information_source: **Be sure to read the version-to-version upgrade notes below** for each upgrade!
|
||||
This document covers information for keeping your system updated through periodic upgrades as well as version-to-version upgrade notes. **Be sure to read these notes for _any_ upgrade!**
|
||||
|
||||
# Before Upgrading
|
||||
* Always back up your system! (See [Administration](./docs/admin/administration.md))
|
||||
* Seriously, always back up your system!
|
||||
1. Always back up your system! (See [Administration - Backing Up Your System](./docs/_docs/admin/administration.md#backing-up-your-system))
|
||||
2. Seriously, always back up your system!
|
||||
3. Review the version to version release notes within this document.
|
||||
4. [Upgrade](./docs/_docs/admin/upgrading.md)
|
||||
|
||||
# General Notes
|
||||
## Configuration File Updates
|
||||
In general, look at template menu files in `misc/menu_templates`, and `config_template.in.hjson` as well as the default `luciano_blocktronics/theme.hjson` files when you update. These files may come with new sections you wish to merge into your system!
|
||||
# The Upgrade Process
|
||||
ENiGMA½ does not currently have much of a "release process" in that instead, it is expected that if you want new features, you will `git pull` them to your system.
|
||||
|
||||
### Menus & Theme Updates
|
||||
Upgrades often come with changes to the default menu templates found in `misc/menu_tempaltes`. You can use these as references for changes and additions to the default menu sets. This also applies to the default `luciano_blocktronics` theme and it's `theme.hjson` file.
|
||||
|
||||
See [Updating](./docs/admin/updating.md) for details on menu files/etc.
|
||||
|
||||
# Upgrading the Code
|
||||
Upgrading from GitHub is easy:
|
||||
|
||||
```bash
|
||||
cd /path/to/enigma-bbs
|
||||
git pull
|
||||
rm -rf npm_modules # do this any time you update Node.js itself
|
||||
npm install # or simply 'yarn'
|
||||
```
|
||||
Refer to [Upgrading](./docs/_docs/admin/upgrading.md) for details around this process.
|
||||
|
||||
# Problems
|
||||
1. Check [TROUBLESHOOTING](TROUBLESHOOTING.md) first.
|
||||
2. Report your issue on Xibalba BBS, hop in #`enigma-bbs` on FreeNode and chat, or [file a issue on GitHub](https://github.com/NuSkooler/enigma-bbs/issues) if you believe you've found a bug or missing feature.
|
||||
2. Report your issue on [Xibalba BBS](https://xibalba.l33t.codes), or [file a issue on GitHub](https://github.com/NuSkooler/enigma-bbs/issues)!
|
||||
|
||||
|
||||
# Version to Version Notes
|
||||
> :warning: Be sure to inspect these notes during any upgrades!
|
||||
|
||||
## 0.0.13-beta to 0.0.14-beta
|
||||
* A new ActivityPub menu template has been created. Upgrades will **not** have this file present so you will need to copy the template to your `config/menus` directory and rename it appropriately (it must match the `include` statement in your main `menu.hjson` file). Example:
|
||||
|
||||
```bash
|
||||
cp ./misc/menu_templates/activitypub.in.hjson ./config/menus/my_board_name-activitypub.hjson`
|
||||
```
|
||||
|
||||
This will expose the default ActivityPub setup. Enabling ActivityPub functionality requires the web server enabled and ActivityPub itself enabled in your `config.hjson`. See [Configuration Files Include Statements](./docs/_docs/configuration/config-files.md#includes) for more information on using `include`.
|
||||
|
||||
* ⚠ The menu flag `noHistory` has been revamped to work as expected. Some menu entires now need this flag. Look for any "NoResults" entries and remove `menuFlags`. For example, here is the (updated) default `fileBaseListEntriesNoResults` menu:
|
||||
|
||||
```hjson
|
||||
fileBaseListEntriesNoResults: {
|
||||
desc: Browsing Files
|
||||
art: FBNORES
|
||||
config: {
|
||||
pause: true
|
||||
// no menuFlags here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See also: [Menu Modules](./docs/_docs/modding/menu-module.md).
|
||||
|
||||
|
||||
* Due to changes to supported algorithms in newer versions of openssl, the default list of supported algorithms for the ssh login server has changed. There are both removed ciphers as well as optional new kex algorithms available now. ***NOTE:*** Changes to supported algorithms are only needed to support keys generated with new versions of openssl, if you already have a ssl key in use you should not have to make any changes to your config.
|
||||
* Removed ciphers: 'blowfish-cbc', 'arcfour256', 'arcfour128', and 'cast128-cbc'
|
||||
|
|
16
WHATSNEW.md
16
WHATSNEW.md
|
@ -2,13 +2,21 @@
|
|||
This document attempts to track **major** changes and additions in ENiGMA½. For details, see GitHub.
|
||||
|
||||
## 0.0.14-beta
|
||||
* The [Web Server](/docs/_docs/servers/contentservers/web-server.md) has made some possibly breaking changes:
|
||||
* **ActivityPub & Mastodon Support**
|
||||
* A new [ActivityPub Web Handler](./docs/_docs/servers/contentservers/activitypub-handler.md) has been added
|
||||
* [Web Server](/docs/_docs/servers/contentservers/web-server.md) has made many changes, **some possibly breaking**:
|
||||
* `/static/` prefixes are no longer required. This was a ugly hack.
|
||||
* Some internal routes such as those used for password resets live within `/_internal/`.
|
||||
* Some internal routes such as those used for password resets live within `/_enig/`.
|
||||
* Routes for the file base now default to `/_f/` prefixed instead of just `/f/`. If `/f/` is in your `config.hjson` you are encouraged to update it!
|
||||
* Finally, the system will search for `index.html` and `index.htm` in that order, if another suitable route cannot be established.
|
||||
* Web activity now has it's own logging configuration under `contentHandlers.web.logging`; The format is the same as the systems standard logging and defaults to a `enigma-bbs.web.log` rotating file at `info` level.
|
||||
* Smaller [Web Handler](/docs/_docs/servers/contentservers/web-handlers.md) modules are now easy to add, a number of which exist by default.
|
||||
* [WebFinger](/docs/_docs/servers/contentservers/webfinger-handler.md) support (Web Handler)
|
||||
* New users now have randomly generated avatars assigned to them that can be served up via the new System General [Web Handler](/docs/_docs/servers/contentservers/web-handlers.md).
|
||||
* CombatNet has shut down, so the module (`combatnet.js`) has been removed.
|
||||
* The Menu Flag `popParent` has been removed and `noHistory` has been updated to work as expected. In general things should "Just Work", but check your `menu.hjson` entries if you see menu stack issues.
|
||||
* New `NewUserPrePersist` system event available to developers to 'hook' account creation and add their own properties/etc.
|
||||
* The signature for `viewValidationListener`'s callback has changed: It is now `(err, newFocusId)`. To ignore a validation error, implementors can simply call the callback with a `null` error, else they should forward it on.
|
||||
* The Menu Flag `popParent` has been removed and `noHistory` has been updated to work as expected. In general things should "Just Work", but do see [UPGRADE](UPGRADE.md) for additional details.
|
||||
* Various New User Application (NUA) properties are now optional. If you would like to reduce the information users are required, remove optional fields from NUA artwork and collect less. These properties will be stored as "" (empty). Optional properties are as follows: Real name, Birth date, Sex, Location, Affiliations (Affils), Email, and Web address.
|
||||
* Art handling has been changed to respect the art width contained in SAUCE when present in the case where the terminal width is greater than the art width. This fixes art files that assume wrapping at 80 columns on wide (mostly new utf8) terminals.
|
||||
|
||||
|
@ -29,7 +37,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For
|
|||
* Additional options in the `abracadabra` module for launching doors. See [Local Doors](./docs/modding/local-doors.md)
|
||||
|
||||
## 0.0.12-beta
|
||||
* The `master` branch has become mainline. What this means to users is `git pull` will always give you the latest and greatest. Make sure to read [Updating](./docs/admin/updating.md) and keep an eye on `WHATSNEW.md` (this file) and [UPGRADE](UPGRADE.md)! See also [ticket #276](https://github.com/NuSkooler/enigma-bbs/issues/276).
|
||||
* The `master` branch has become mainline. What this means to users is `git pull` will always give you the latest and greatest. Make sure to read [Upgrading](./docs/admin/upgrading.md) and keep an eye on `WHATSNEW.md` (this file) and [UPGRADE](UPGRADE.md)! See also [ticket #276](https://github.com/NuSkooler/enigma-bbs/issues/276).
|
||||
* Development now occurs against [Node.js 14 LTS](https://github.com/nodejs/node/blob/master/doc/changelogs/CHANGELOG_V14.md).
|
||||
* The default configuration has been moved to [config_default.js](/core/config_default.js).
|
||||
* A full configuration revamp has taken place. Configuration files such as `config.hjson`, `menu.hjson`, and `theme.hjson` can now utilize includes via the `includes` directive, reference 'self' sections using `@reference:` and import environment variables with `@environment`.
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -97,7 +97,7 @@
|
|||
mci: {
|
||||
VM1: {
|
||||
height: 10
|
||||
width: 20
|
||||
width: 71
|
||||
itemFormat: "|00|11{userName:<12}|08: |03{oneliner:<59.59}"
|
||||
}
|
||||
TM2: {
|
||||
|
@ -229,6 +229,7 @@
|
|||
VM1: {
|
||||
height: 10
|
||||
itemFormat: "|00|11{userName:<12}|08: |03{oneliner:<59.59}"
|
||||
width: 71
|
||||
}
|
||||
TM2: {
|
||||
focusTextStyle: first lower
|
||||
|
@ -400,6 +401,7 @@
|
|||
TL1: { width: 19, textOverflow: "..." }
|
||||
ET2: { width: 19, textOverflow: "..." }
|
||||
ET3: { width: 19, textOverflow: "..." }
|
||||
ET4: { width: 21, textOverflow: "..." }
|
||||
}
|
||||
}
|
||||
1: {
|
||||
|
@ -459,7 +461,7 @@
|
|||
VM1: {
|
||||
height: 11
|
||||
width: 22
|
||||
focusTextStyle: first upper
|
||||
focusTextStyle: first lower
|
||||
itemFormat: "|00|07{bbsName}"
|
||||
focusItemFormat: "|00|19|15{bbsName!styleFirstLower}"
|
||||
}
|
||||
|
@ -485,7 +487,7 @@
|
|||
ET8: { width: 32 }
|
||||
|
||||
TM17: {
|
||||
focusTextStyle: first upper
|
||||
focusTextStyle: first lower
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -673,7 +675,7 @@
|
|||
4: {
|
||||
mci: {
|
||||
HM1: {
|
||||
focusTextStyle: first upper
|
||||
focusTextStyle: first lower
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -685,6 +687,7 @@
|
|||
TL1: { width: 19, textOverflow: "..." }
|
||||
ET2: { width: 19, textOverflow: "..." }
|
||||
ET3: { width: 19, textOverflow: "..." }
|
||||
ET4: { width: 21, textOverflow: "..." }
|
||||
//TL4: { width: 25 }
|
||||
}
|
||||
}
|
||||
|
@ -1257,6 +1260,182 @@
|
|||
}
|
||||
}
|
||||
|
||||
//
|
||||
// ActivityPub
|
||||
//
|
||||
activityPubUserConfig: {
|
||||
config: {
|
||||
mainInfoFormat10: "{subject}"
|
||||
}
|
||||
0: {
|
||||
mci: {
|
||||
TM1: {
|
||||
focusTextStyle: first lower
|
||||
}
|
||||
TM2: {
|
||||
focusTextStyle: first lower
|
||||
}
|
||||
TM3: {
|
||||
focusTextStyle: first lower
|
||||
}
|
||||
TM4: {
|
||||
focusTextStyle: first lower
|
||||
}
|
||||
TL5: {
|
||||
width: 70
|
||||
}
|
||||
TL6: {
|
||||
width: 70
|
||||
}
|
||||
BT7: {
|
||||
width: 20
|
||||
itemFormat: "|00|08[ |03{text} |08]"
|
||||
focusItemFormat: "|00|15[ |19|15{text}|16 |15]"
|
||||
focusTextStyle: first lower
|
||||
}
|
||||
TM8: {
|
||||
focusTextStyle: first lower
|
||||
}
|
||||
TL10: {
|
||||
width: 40
|
||||
}
|
||||
}
|
||||
}
|
||||
1: {
|
||||
mci: {
|
||||
ML1: {
|
||||
height: 4
|
||||
width: 70
|
||||
}
|
||||
ML2: {
|
||||
height: 4
|
||||
width: 70
|
||||
}
|
||||
TM3: {
|
||||
focusTextStyle: first lower
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activityPubSocialManager: {
|
||||
config: {
|
||||
selectedActorInfoFormat: "|00|15{preferredUsername} |08(|02{name}|08)\n|07following|08: {statusIndicator}\n\n|06{plainTextSummary}"
|
||||
statusFollowing: "|00|10√"
|
||||
statusNotFollowing: "|00|12X"
|
||||
helpTextFollowing: "|00|10SPC |08: |02Toggle Following"
|
||||
helpTextFollowers: "|00|10DEL |08: |02Remove Follower"
|
||||
helpTextFollowRequests: "|00|10SPC |08: |02Accept\r\n|10DEL |08: |02Deny"
|
||||
mainInfoFormat10: "{helpText}"
|
||||
}
|
||||
0: {
|
||||
mci: {
|
||||
VM1: {
|
||||
height: 15
|
||||
width: 35
|
||||
itemFormat: "|00|03{subject}|00 {statusIndicator}"
|
||||
focusItemFormat: "|00|19|15{subject!styleUpper}|00 {statusIndicator}"
|
||||
itemFormat: "|00|08{statusIndicator} |00|03{subject}"
|
||||
focusItemFormat: "|00|08{statusIndicator} |00|19|15{subject}"
|
||||
textOverflow: "..."
|
||||
}
|
||||
MT2: {
|
||||
height: 15
|
||||
width: 34
|
||||
}
|
||||
HM3: {
|
||||
focusTextStyle: first lower
|
||||
styleSGR1: "|00|08"
|
||||
}
|
||||
MT10: {
|
||||
width: 22
|
||||
height: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activityPubActorSearch: {
|
||||
config: {
|
||||
followingIndicator: "|00|14FOLLOWING"
|
||||
notFollowingIndicator: "|00|12not following"
|
||||
viewInfoFormat10: "{actorFollowingIndicator}"
|
||||
}
|
||||
0: {
|
||||
mci: {
|
||||
TL1: {
|
||||
width: 70
|
||||
submit: true
|
||||
}
|
||||
}
|
||||
}
|
||||
1: {
|
||||
mci: {
|
||||
TL1: {
|
||||
width: 70
|
||||
}
|
||||
TL2: {
|
||||
width: 70
|
||||
}
|
||||
TL3: {
|
||||
width: 70
|
||||
}
|
||||
TL4: {
|
||||
width: 10
|
||||
}
|
||||
TL5: {
|
||||
width: 4
|
||||
}
|
||||
TL6: {
|
||||
width: 4
|
||||
}
|
||||
MT7: {
|
||||
focus: true
|
||||
width: 69
|
||||
height: 3
|
||||
mode: preview
|
||||
}
|
||||
TL10: {
|
||||
width: 24
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activityPubPostPublicMessage: {
|
||||
0: {
|
||||
mci: {
|
||||
TL1: { width: 19, textOverflow: "..." }
|
||||
ET2: { width: 19, textOverflow: "..." }
|
||||
ET3: { width: 19, textOverflow: "..." }
|
||||
ET4: { width: 21, textOverflow: "..." }
|
||||
//TL4: { width: 25 }
|
||||
}
|
||||
}
|
||||
1: {
|
||||
mci: {
|
||||
MT1: { height: 14 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activityPubPublicMessages: {
|
||||
config: {
|
||||
dateTimeFormat: ddd MMM Do
|
||||
allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}"
|
||||
}
|
||||
mci: {
|
||||
VM1: {
|
||||
height: 14
|
||||
width: 70
|
||||
itemFormat: "|00|15{msgNum:>4} |03{subject:<28.27} |11{fromUserName:<20.20} |03{ts:<15.16} |15{newIndicator}"
|
||||
focusItemFormat: "|00|19|15{msgNum:>4} {subject:<28.27} {fromUserName:<20.20} {ts:<15.16} {newIndicator}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MRC
|
||||
mrc: {
|
||||
config: {
|
||||
messageFormat: "|00|10<|02{fromUserName}|10>|00 |03{message}|00"
|
||||
|
@ -1336,6 +1515,14 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
activityPubMenuCommand: {
|
||||
mci: {
|
||||
TL1: {
|
||||
text: "|00|08(|11|AS|08)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
achievements: {
|
||||
|
|
|
@ -936,6 +936,8 @@ function peg$parse(input, options) {
|
|||
const UserProps = require('./user_property.js');
|
||||
const Log = require('./logger.js').log;
|
||||
const User = require('./user.js');
|
||||
const Config = require('./config.js').get;
|
||||
const ActivityPubSettings = require('./activitypub/settings');
|
||||
|
||||
const _ = require('lodash');
|
||||
const moment = require('moment');
|
||||
|
@ -946,6 +948,97 @@ function peg$parse(input, options) {
|
|||
function checkAccess(acsCode, value) {
|
||||
try {
|
||||
return {
|
||||
AE: function activityPubEnabled() {
|
||||
const apSettings = ActivityPubSettings.fromUser(user);
|
||||
switch (value) {
|
||||
case 0:
|
||||
return !apSettings.enabled;
|
||||
case 1:
|
||||
return apSettings.enabled;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
SE: function servicesEnabled() {
|
||||
if (!Array.isArray(value)) {
|
||||
value = [value];
|
||||
}
|
||||
const config = Config();
|
||||
const webEnabled = () => {
|
||||
return (
|
||||
true === _.get(config, 'contentServers.web.http.enabled') ||
|
||||
true === _.get(config, 'contentServers.web.https.enabled')
|
||||
);
|
||||
};
|
||||
|
||||
const allEnabled = value.every(svcName => {
|
||||
switch (svcName) {
|
||||
case 'http':
|
||||
return (
|
||||
true ===
|
||||
_.get(config, 'contentServers.web.http.enabled')
|
||||
);
|
||||
|
||||
case 'https':
|
||||
return (
|
||||
true ===
|
||||
_.get(config, 'contentServers.web.https.enabled')
|
||||
);
|
||||
|
||||
case 'web':
|
||||
return webEnabled();
|
||||
|
||||
case 'gopher':
|
||||
return (
|
||||
true ===
|
||||
_.get(config, 'contentServers.gopher.enabled')
|
||||
);
|
||||
|
||||
case 'nttp':
|
||||
return (
|
||||
true ===
|
||||
_.get(config, 'contentServers.nntp.nntp.enabled')
|
||||
);
|
||||
|
||||
case 'nntps':
|
||||
return (
|
||||
true ===
|
||||
_.get(config, 'contentServers.nntp.nntps.enabled')
|
||||
);
|
||||
|
||||
case 'activitypub':
|
||||
return (
|
||||
webEnabled() &&
|
||||
true ===
|
||||
_.get(
|
||||
config,
|
||||
'contentServers.web.handlers.activityPub.enabled'
|
||||
)
|
||||
);
|
||||
|
||||
case 'nodeinfo2':
|
||||
return (
|
||||
webEnabled() &&
|
||||
true ===
|
||||
_.get(
|
||||
config,
|
||||
'contentServers.web.handlers.nodeInfo2.enabled'
|
||||
)
|
||||
);
|
||||
|
||||
case 'webfinger':
|
||||
return (
|
||||
webEnabled() &&
|
||||
true ===
|
||||
_.get(
|
||||
config,
|
||||
'contentServers.web.handlers.webFinger.enabled'
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
return allEnabled;
|
||||
},
|
||||
LC: function isLocalConnection() {
|
||||
return client && client.isLocal();
|
||||
},
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
const { WellKnownActivityTypes, WellKnownActivity } = require('./const');
|
||||
const { recipientIdsFromObject } = require('./util');
|
||||
const ActivityPubObject = require('./object');
|
||||
const { getISOTimestampString } = require('../database');
|
||||
|
||||
module.exports = class Activity extends ActivityPubObject {
|
||||
constructor(obj, withContext = ActivityPubObject.DefaultContext) {
|
||||
super(obj, withContext);
|
||||
}
|
||||
|
||||
static get ActivityTypes() {
|
||||
return WellKnownActivityTypes;
|
||||
}
|
||||
|
||||
static fromJsonString(s) {
|
||||
const obj = ActivityPubObject.fromJsonString(s);
|
||||
return new Activity(obj);
|
||||
}
|
||||
|
||||
static makeFollow(localActor, remoteActor) {
|
||||
return new Activity({
|
||||
id: Activity.activityObjectId(),
|
||||
type: WellKnownActivity.Follow,
|
||||
actor: localActor,
|
||||
object: remoteActor.id,
|
||||
});
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/activitypub/#accept-activity-inbox
|
||||
static makeAccept(localActor, activity) {
|
||||
return new Activity({
|
||||
id: Activity.activityObjectId(),
|
||||
type: WellKnownActivity.Accept,
|
||||
actor: localActor,
|
||||
object: activity, // previous request Activity
|
||||
});
|
||||
}
|
||||
|
||||
static makeReject(localActor, activity) {
|
||||
return new Activity({
|
||||
id: Activity.activityObjectId(),
|
||||
type: WellKnownActivity.Reject,
|
||||
actor: localActor.id,
|
||||
object: activity,
|
||||
});
|
||||
}
|
||||
|
||||
static makeCreate(actor, obj, context) {
|
||||
const activity = new Activity(
|
||||
{
|
||||
id: Activity.activityObjectId(),
|
||||
to: obj.to,
|
||||
type: WellKnownActivity.Create,
|
||||
actor,
|
||||
object: obj,
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
const copy = n => {
|
||||
if (obj[n]) {
|
||||
activity[n] = obj[n];
|
||||
}
|
||||
};
|
||||
|
||||
copy('to');
|
||||
copy('cc');
|
||||
// :TODO: Others?
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
static makeTombstone(obj) {
|
||||
const deleted = getISOTimestampString();
|
||||
return new Activity({
|
||||
id: obj.id,
|
||||
type: WellKnownActivity.Tombstone,
|
||||
deleted,
|
||||
published: deleted,
|
||||
updated: deleted,
|
||||
});
|
||||
}
|
||||
|
||||
recipientIds() {
|
||||
return recipientIdsFromObject(this);
|
||||
}
|
||||
|
||||
static activityObjectId() {
|
||||
return ActivityPubObject.makeObjectId('activity');
|
||||
}
|
||||
};
|
|
@ -0,0 +1,313 @@
|
|||
/* jslint node: true */
|
||||
'use strict';
|
||||
|
||||
// ENiGMA½
|
||||
const { Errors } = require('../enig_error.js');
|
||||
const UserProps = require('../user_property');
|
||||
const Endpoints = require('./endpoint');
|
||||
const { userNameFromSubject, isValidLink } = require('./util');
|
||||
const Log = require('../logger').log;
|
||||
const { queryWebFinger } = require('../webfinger');
|
||||
const EnigAssert = require('../enigma_assert');
|
||||
const ActivityPubSettings = require('./settings');
|
||||
const ActivityPubObject = require('./object');
|
||||
const { ActivityStreamMediaType, Collections } = require('./const');
|
||||
const Config = require('../config').get;
|
||||
const { stripMciColorCodes } = require('../color_codes');
|
||||
const { stripAnsiControlCodes } = require('../string_util');
|
||||
|
||||
// deps
|
||||
const _ = require('lodash');
|
||||
const mimeTypes = require('mime-types');
|
||||
const { getJson } = require('../http_util.js');
|
||||
const moment = require('moment');
|
||||
const paths = require('path');
|
||||
const Collection = require('./collection.js');
|
||||
|
||||
const ActorCacheExpiration = moment.duration(15, 'days');
|
||||
const ActorCacheMaxAgeDays = 125; // hasn't been used in >= 125 days, nuke it.
|
||||
|
||||
// default context for Actor's
|
||||
const DefaultContext = ActivityPubObject.makeContext(['https://w3id.org/security/v1'], {
|
||||
toot: 'http://joinmastodon.org/ns#',
|
||||
discoverable: 'toot:discoverable',
|
||||
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
|
||||
});
|
||||
|
||||
// https://www.w3.org/TR/activitypub/#actor-objects
|
||||
module.exports = class Actor extends ActivityPubObject {
|
||||
constructor(obj, withContext = DefaultContext) {
|
||||
super(obj, withContext);
|
||||
}
|
||||
|
||||
isValid() {
|
||||
if (!super.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Actor.WellKnownActorTypes.includes(this.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const linksValid = Actor.WellKnownLinkTypes.every(l => {
|
||||
// must be valid if present & non-empty
|
||||
if (this[l] && !isValidLink(this[l])) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!linksValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static fromJsonString(s) {
|
||||
const obj = ActivityPubObject.fromJsonString(s);
|
||||
return new Actor(obj);
|
||||
}
|
||||
|
||||
static get WellKnownActorTypes() {
|
||||
return ['Person', 'Group', 'Organization', 'Service', 'Application'];
|
||||
}
|
||||
|
||||
static get WellKnownLinkTypes() {
|
||||
return [
|
||||
Collections.Inbox,
|
||||
Collections.Outbox,
|
||||
Collections.Following,
|
||||
Collections.Followers,
|
||||
];
|
||||
}
|
||||
|
||||
static fromLocalUser(user, cb) {
|
||||
const userActorId = user.getProperty(UserProps.ActivityPubActorId);
|
||||
if (!userActorId) {
|
||||
return cb(
|
||||
Errors.MissingProperty(
|
||||
`User missing '${UserProps.ActivityPubActorId}' property`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const userSettings = ActivityPubSettings.fromUser(user);
|
||||
|
||||
const addImage = (o, t) => {
|
||||
const url = userSettings[t];
|
||||
if (url) {
|
||||
const fn = paths.basename(url);
|
||||
const mt =
|
||||
mimeTypes.contentType(fn) || mimeTypes.contentType('dummy.png');
|
||||
if (mt) {
|
||||
o[t] = {
|
||||
mediaType: mt,
|
||||
type: 'Image',
|
||||
url,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const summary = stripMciColorCodes(
|
||||
stripAnsiControlCodes(user.getProperty(UserProps.AutoSignature) || ''),
|
||||
{ mode: 'nonAsciiPrintable' }
|
||||
);
|
||||
|
||||
const obj = {
|
||||
id: userActorId,
|
||||
type: 'Person',
|
||||
preferredUsername: user.username,
|
||||
name: userSettings.showRealName
|
||||
? user.getSanitizedName('real')
|
||||
: user.username,
|
||||
endpoints: {
|
||||
sharedInbox: Endpoints.sharedInbox(),
|
||||
},
|
||||
inbox: Endpoints.inbox(user),
|
||||
outbox: Endpoints.outbox(user),
|
||||
followers: Endpoints.followers(user),
|
||||
following: Endpoints.following(user),
|
||||
summary,
|
||||
url: Endpoints.profile(user),
|
||||
manuallyApprovesFollowers: userSettings.manuallyApprovesFollowers,
|
||||
discoverable: userSettings.discoverable,
|
||||
// :TODO: we can start to define BBS related stuff with the community perhaps
|
||||
// attachment: [
|
||||
// {
|
||||
// name: 'SomeNetwork Address',
|
||||
// type: 'PropertyValue',
|
||||
// value: 'Mateo@21:1/121',
|
||||
// },
|
||||
// ],
|
||||
|
||||
// :TODO: re-enable once a spec is defined; board should prob be a object with connection info, etc.
|
||||
// bbsInfo: {
|
||||
// boardName: Config().general.boardName,
|
||||
// memberSince: user.getProperty(UserProps.AccountCreated),
|
||||
// affiliations: user.getProperty(UserProps.Affiliations) || '',
|
||||
// },
|
||||
};
|
||||
|
||||
addImage(obj, 'icon');
|
||||
addImage(obj, 'image');
|
||||
|
||||
const publicKeyPem = user.getProperty(UserProps.PublicActivityPubSigningKey);
|
||||
if (!_.isEmpty(publicKeyPem)) {
|
||||
obj.publicKey = {
|
||||
id: userActorId + '#main-key',
|
||||
owner: userActorId,
|
||||
publicKeyPem,
|
||||
};
|
||||
|
||||
EnigAssert(
|
||||
!_.isEmpty(user.getProperty(UserProps.PrivateActivityPubSigningKey)),
|
||||
'User has public key but no private key!'
|
||||
);
|
||||
} else {
|
||||
Log.warn(
|
||||
{ username: user.username },
|
||||
`No public key (${UserProps.PublicActivityPubSigningKey}) for user "${user.username}"`
|
||||
);
|
||||
}
|
||||
|
||||
return cb(null, new Actor(obj));
|
||||
}
|
||||
|
||||
static fromId(id, cb) {
|
||||
let delivered = false;
|
||||
const callback = (e, a, s) => {
|
||||
if (!delivered) {
|
||||
delivered = true;
|
||||
return cb(e, a, s);
|
||||
}
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
return cb(Errors.Invalid('Invalid Actor ID'));
|
||||
}
|
||||
|
||||
Actor._fromCache(id, (err, actor, subject, needsRefresh) => {
|
||||
if (!err) {
|
||||
// cache hit
|
||||
callback(null, actor, subject);
|
||||
|
||||
if (!needsRefresh) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or needs refreshed; Try to do so now
|
||||
Actor._fromWebFinger(id, (err, actor, subject) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (subject) {
|
||||
subject = `@${userNameFromSubject(subject)}`; // e.g. @Username@host.com
|
||||
} else if (!_.isEmpty(actor)) {
|
||||
subject = actor.id; // best we can do for now
|
||||
}
|
||||
|
||||
// deliver result to caller
|
||||
callback(err, actor, subject);
|
||||
|
||||
// cache our entry
|
||||
if (actor) {
|
||||
Collection.addActor(actor, subject, err => {
|
||||
if (err) {
|
||||
// :TODO: Log me
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static actorCacheMaintenanceTask(args, cb) {
|
||||
const enabled = _.get(
|
||||
Config(),
|
||||
'contentServers.web.handlers.activityPub.enabled'
|
||||
);
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
Collection.removeExpiredActors(ActorCacheMaxAgeDays, err => {
|
||||
if (err) {
|
||||
Log.error('Failed removing expired Actor items');
|
||||
}
|
||||
|
||||
return cb(null); // always non-fatal
|
||||
});
|
||||
}
|
||||
|
||||
static _fromRemoteQuery(id, cb) {
|
||||
const headers = {
|
||||
Accept: ActivityStreamMediaType,
|
||||
};
|
||||
|
||||
getJson(id, { headers }, (err, actor) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
actor = new Actor(actor);
|
||||
|
||||
if (!actor.isValid()) {
|
||||
return cb(Errors.Invalid('Invalid Actor'));
|
||||
}
|
||||
|
||||
return cb(null, actor);
|
||||
});
|
||||
}
|
||||
|
||||
static _fromCache(actorIdOrSubject, cb) {
|
||||
Collection.actor(actorIdOrSubject, (err, actor, info) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
const needsRefresh = moment().isAfter(
|
||||
info.timestamp.add(ActorCacheExpiration)
|
||||
);
|
||||
|
||||
actor = new Actor(actor);
|
||||
if (!actor.isValid()) {
|
||||
return cb(Errors.Invalid('Failed to create Actor object'));
|
||||
}
|
||||
|
||||
return cb(null, actor, info.subject, needsRefresh);
|
||||
});
|
||||
}
|
||||
|
||||
static _fromWebFinger(actorQuery, cb) {
|
||||
queryWebFinger(actorQuery, (err, res) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
// we need a link with 'application/activity+json'
|
||||
const links = res.links;
|
||||
if (!Array.isArray(links)) {
|
||||
return cb(Errors.DoesNotExist('No "links" object in WebFinger response'));
|
||||
}
|
||||
|
||||
const activityLink = links.find(l => {
|
||||
return l.type === ActivityStreamMediaType && l.href?.length > 0;
|
||||
});
|
||||
|
||||
if (!activityLink) {
|
||||
return cb(
|
||||
Errors.DoesNotExist('No Activity link found in WebFinger response')
|
||||
);
|
||||
}
|
||||
|
||||
// we can now query the href value for an Actor
|
||||
return Actor._fromRemoteQuery(activityLink.href, (err, actor) => {
|
||||
return cb(err, actor, res.subject);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,364 @@
|
|||
const { MenuModule } = require('../menu_module');
|
||||
const { Errors } = require('../enig_error');
|
||||
const Actor = require('../activitypub/actor');
|
||||
const moment = require('moment');
|
||||
const { htmlToMessageBody } = require('./util');
|
||||
const { Collections } = require('./const');
|
||||
const Collection = require('./collection');
|
||||
const EnigAssert = require('../enigma_assert');
|
||||
const { sendFollowRequest, sendUnfollowRequest } = require('./follow_util');
|
||||
const { getServer } = require('../listening_server');
|
||||
const UserProps = require('../user_property');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
const { get, isEmpty, isObject, cloneDeep } = require('lodash');
|
||||
|
||||
exports.moduleInfo = {
|
||||
name: 'ActivityPub Actor Search',
|
||||
desc: 'Menu item to search for an ActivityPub actor',
|
||||
author: 'CognitiveGears',
|
||||
};
|
||||
|
||||
const FormIds = {
|
||||
main: 0,
|
||||
view: 1,
|
||||
};
|
||||
|
||||
const MciViewIds = {
|
||||
main: {
|
||||
searchQuery: 1,
|
||||
},
|
||||
view: {
|
||||
userName: 1,
|
||||
fullName: 2,
|
||||
datePublished: 3,
|
||||
manualFollowers: 4,
|
||||
numberFollowers: 5,
|
||||
numberFollowing: 6,
|
||||
summary: 7,
|
||||
|
||||
customRangeStart: 10,
|
||||
},
|
||||
};
|
||||
|
||||
exports.getModule = class ActivityPubActorSearch extends MenuModule {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
this.config = Object.assign({}, get(options, 'menuConfig.config'), {
|
||||
extraArgs: options.extraArgs,
|
||||
});
|
||||
|
||||
this.menuMethods = {
|
||||
search: (formData, extraArgs, cb) => {
|
||||
return this._search(formData.value, cb);
|
||||
},
|
||||
toggleFollowKeyPressed: (formData, extraArgs, cb) => {
|
||||
return this._toggleFollowStatus(err => {
|
||||
if (err) {
|
||||
this.client.log.error(
|
||||
{ error: err.message },
|
||||
'Failed to toggle follow status'
|
||||
);
|
||||
}
|
||||
return cb(err);
|
||||
});
|
||||
},
|
||||
backKeyPressed: (formData, extraArgs, cb) => {
|
||||
return this._displayMainPage(true, cb);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
initSequence() {
|
||||
this.webServer = getServer('codes.l33t.enigma.web.server');
|
||||
if (!this.webServer) {
|
||||
this.client.log('Could not get Web server');
|
||||
return this.prevMenu();
|
||||
}
|
||||
this.webServer = this.webServer.instance;
|
||||
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return this.beforeArt(callback);
|
||||
},
|
||||
callback => {
|
||||
return this._displayMainPage(false, callback);
|
||||
},
|
||||
],
|
||||
() => {
|
||||
this.finishedLoading();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_search(values, cb) {
|
||||
const searchString = values.searchQuery.trim();
|
||||
//TODO: Handle empty searchString
|
||||
Actor.fromId(searchString, (err, remoteActor) => {
|
||||
if (err) {
|
||||
this.client.log.warn(
|
||||
{ remoteActor: remoteActor, err: err },
|
||||
'Failure to search for actor'
|
||||
);
|
||||
// TODO: Add error to page for failure to find actor
|
||||
return this._displayMainPage(true, cb);
|
||||
}
|
||||
|
||||
this.selectedActorInfo = remoteActor;
|
||||
return this._displayViewPage(cb);
|
||||
});
|
||||
}
|
||||
|
||||
_displayViewPage(cb) {
|
||||
EnigAssert(isObject(this.selectedActorInfo), 'No Actor selected!');
|
||||
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
if (this.viewControllers.main) {
|
||||
this.viewControllers.main.setFocus(false);
|
||||
}
|
||||
|
||||
return this.displayArtAndPrepViewController(
|
||||
'view',
|
||||
FormIds.view,
|
||||
{ clearScreen: true },
|
||||
(err, artInfo, wasCreated) => {
|
||||
if (!err && !wasCreated) {
|
||||
this.viewControllers.view.setFocus(true);
|
||||
}
|
||||
return callback(err);
|
||||
}
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
return this.validateMCIByViewIds(
|
||||
'view',
|
||||
Object.values(MciViewIds.view).filter(
|
||||
id => id !== MciViewIds.view.customRangeStart
|
||||
),
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
this._updateCollectionItemCount(Collections.Following, () => {
|
||||
return this._updateCollectionItemCount(
|
||||
Collections.Followers,
|
||||
callback
|
||||
);
|
||||
});
|
||||
},
|
||||
callback => {
|
||||
const v = id => this.getView('view', id);
|
||||
|
||||
const nameView = v(MciViewIds.view.userName);
|
||||
nameView.setText(this.selectedActorInfo.preferredUsername);
|
||||
|
||||
const fullNameView = v(MciViewIds.view.fullName);
|
||||
fullNameView.setText(this.selectedActorInfo.name);
|
||||
|
||||
const datePublishedView = v(MciViewIds.view.datePublished);
|
||||
if (isEmpty(this.selectedActorInfo.published)) {
|
||||
datePublishedView.setText('Not available.');
|
||||
} else {
|
||||
const publishedDate = moment(this.selectedActorInfo.published);
|
||||
datePublishedView.setText(
|
||||
publishedDate.format(this.getDateFormat())
|
||||
);
|
||||
}
|
||||
|
||||
const manualFollowersView = v(MciViewIds.view.manualFollowers);
|
||||
manualFollowersView.setText(
|
||||
this.selectedActorInfo.manuallyApprovesFollowers
|
||||
);
|
||||
|
||||
const followerCountView = v(MciViewIds.view.numberFollowers);
|
||||
followerCountView.setText(
|
||||
this.selectedActorInfo._followersCount > -1
|
||||
? this.selectedActorInfo._followersCount
|
||||
: '--'
|
||||
);
|
||||
|
||||
const followingCountView = v(MciViewIds.view.numberFollowing);
|
||||
followingCountView.setText(
|
||||
this.selectedActorInfo._followingCount > -1
|
||||
? this.selectedActorInfo._followingCount
|
||||
: '--'
|
||||
);
|
||||
|
||||
const summaryView = v(MciViewIds.view.summary);
|
||||
summaryView.setText(
|
||||
htmlToMessageBody(this.selectedActorInfo.summary)
|
||||
);
|
||||
summaryView.redraw();
|
||||
|
||||
return this._setFollowStatus(callback);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_setFollowStatus(cb) {
|
||||
Collection.ownedObjectByNameAndId(
|
||||
Collections.Following,
|
||||
this.client.user,
|
||||
this.selectedActorInfo.id,
|
||||
(err, followingActorEntry) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
this.selectedActorInfo._isFollowing = followingActorEntry ? true : false;
|
||||
this.selectedActorInfo._followingIndicator =
|
||||
this._getFollowingIndicator();
|
||||
|
||||
this.updateCustomViewTextsWithFilter(
|
||||
'view',
|
||||
MciViewIds.view.customRangeStart,
|
||||
this._getCustomInfoFormatObject()
|
||||
);
|
||||
|
||||
return cb(null);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_toggleFollowStatus(cb) {
|
||||
// catch early key presses
|
||||
if (!this.selectedActorInfo) {
|
||||
return cb(Errors.UnexpectedState('No Actor selected'));
|
||||
}
|
||||
|
||||
// Don't allow users to follow themselves
|
||||
const currentActorId = this.client.user.getProperty(UserProps.ActivityPubActorId);
|
||||
if (currentActorId === this.selectedActorInfo.id) {
|
||||
return cb(Errors.Invalid('You cannot follow yourself!'));
|
||||
}
|
||||
|
||||
this.selectedActorInfo._isFollowing = !this.selectedActorInfo._isFollowing;
|
||||
this.selectedActorInfo._followingIndicator = this._getFollowingIndicator();
|
||||
|
||||
const finish = e => {
|
||||
this.updateCustomViewTextsWithFilter(
|
||||
'view',
|
||||
MciViewIds.view.customRangeStart,
|
||||
this._getCustomInfoFormatObject()
|
||||
);
|
||||
|
||||
return cb(e);
|
||||
};
|
||||
|
||||
const actor = this._getSelectedActor(); // actor info -> actor
|
||||
return this.selectedActorInfo._isFollowing
|
||||
? sendFollowRequest(this.client.user, actor, finish)
|
||||
: sendUnfollowRequest(this.client.user, actor, finish);
|
||||
}
|
||||
|
||||
_getSelectedActor() {
|
||||
const actor = cloneDeep(this.selectedActorInfo);
|
||||
|
||||
// nuke our added properties
|
||||
delete actor._isFollowing;
|
||||
delete actor._followingIndicator;
|
||||
delete actor._followingCount;
|
||||
delete actor._followersCount;
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
_getFollowingIndicator() {
|
||||
return this.selectedActorInfo._isFollowing
|
||||
? this.config.followingIndicator || 'Following'
|
||||
: this.config.notFollowingIndicator || 'Not following';
|
||||
}
|
||||
|
||||
_getCustomInfoFormatObject() {
|
||||
const formatObj = {
|
||||
followingCount: this.selectedActorInfo._followingCount,
|
||||
followerCount: this.selectedActorInfo._followersCount,
|
||||
};
|
||||
|
||||
const v = f => {
|
||||
return this.selectedActorInfo[f] || '';
|
||||
};
|
||||
|
||||
Object.assign(formatObj, {
|
||||
actorId: v('id'),
|
||||
actorSubject: v('subject'),
|
||||
actorType: v('type'),
|
||||
actorName: v('name'),
|
||||
actorSummary: v('summary'),
|
||||
actorPreferredUsername: v('preferredUsername'),
|
||||
actorUrl: v('url'),
|
||||
actorImage: v('image'),
|
||||
actorIcon: v('icon'),
|
||||
actorFollowing: this.selectedActorInfo._isFollowing,
|
||||
actorFollowingIndicator: v('_followingIndicator'),
|
||||
text: v('name'),
|
||||
});
|
||||
|
||||
return formatObj;
|
||||
}
|
||||
|
||||
_displayMainPage(clearScreen, cb) {
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
if (this.viewControllers.view) {
|
||||
this.viewControllers.view.setFocus(false);
|
||||
}
|
||||
return this.displayArtAndPrepViewController(
|
||||
'main',
|
||||
FormIds.main,
|
||||
{ clearScreen },
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
return this.validateMCIByViewIds(
|
||||
'main',
|
||||
Object.values(MciViewIds.main),
|
||||
callback
|
||||
);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_updateCollectionItemCount(collectionName, cb) {
|
||||
const collectionUrl = this.selectedActorInfo[collectionName];
|
||||
this._retrieveCountFromCollectionUrl(collectionUrl, (err, count) => {
|
||||
if (err) {
|
||||
this.client.log.warn(
|
||||
{ err: err },
|
||||
`Unable to get Collection count for ${collectionUrl}`
|
||||
);
|
||||
this.selectedActorInfo[`_${collectionName}Count`] = -1;
|
||||
} else {
|
||||
this.selectedActorInfo[`_${collectionName}Count`] = count;
|
||||
}
|
||||
|
||||
return cb(null);
|
||||
});
|
||||
}
|
||||
|
||||
_retrieveCountFromCollectionUrl(collectionUrl, cb) {
|
||||
collectionUrl = collectionUrl.trim();
|
||||
if (isEmpty(collectionUrl)) {
|
||||
return cb(Errors.UnexpectedState('Count URL can not be empty.'));
|
||||
}
|
||||
|
||||
Collection.getRemoteCollectionStats(collectionUrl, (err, stats) => {
|
||||
return cb(err, err ? null : stats.totalItems);
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,754 @@
|
|||
const { parseTimestampOrNow } = require('./util');
|
||||
const Endpoints = require('./endpoint');
|
||||
const ActivityPubObject = require('./object');
|
||||
const apDb = require('../database').dbs.activitypub;
|
||||
const { getISOTimestampString } = require('../database');
|
||||
const { Errors } = require('../enig_error.js');
|
||||
const {
|
||||
PublicCollectionId,
|
||||
ActivityStreamMediaType,
|
||||
Collections,
|
||||
ActorCollectionId,
|
||||
} = require('./const');
|
||||
const UserProps = require('../user_property');
|
||||
const { getJson } = require('../http_util');
|
||||
|
||||
// deps
|
||||
const { isString } = require('lodash');
|
||||
const Log = require('../logger').log;
|
||||
const async = require('async');
|
||||
|
||||
module.exports = class Collection extends ActivityPubObject {
|
||||
constructor(obj) {
|
||||
super(obj);
|
||||
}
|
||||
|
||||
static getRemoteCollectionStats(collectionUrl, cb) {
|
||||
const headers = {
|
||||
Accept: ActivityStreamMediaType,
|
||||
};
|
||||
|
||||
getJson(
|
||||
collectionUrl,
|
||||
{ headers, validContentTypes: [ActivityStreamMediaType] },
|
||||
(err, collection) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
collection = new Collection(collection);
|
||||
if (!collection.isValid()) {
|
||||
return cb(Errors.Invalid('Invalid Collection'));
|
||||
}
|
||||
|
||||
const { totalItems, type, id, summary } = collection;
|
||||
|
||||
return cb(null, {
|
||||
totalItems,
|
||||
type,
|
||||
id,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static followers(collectionId, page, cb) {
|
||||
return Collection.publicOrderedById(
|
||||
Collections.Followers,
|
||||
collectionId,
|
||||
page,
|
||||
e => e.id,
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
static following(collectionId, page, cb) {
|
||||
return Collection.publicOrderedById(
|
||||
Collections.Following,
|
||||
collectionId,
|
||||
page,
|
||||
e => e.id,
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
static followRequests(owningUser, page, cb) {
|
||||
return Collection.ownedOrderedByUser(
|
||||
Collections.FollowRequests,
|
||||
owningUser,
|
||||
true, // private
|
||||
page,
|
||||
null, // return full Follow Request Activity
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
static outbox(collectionId, page, cb) {
|
||||
return Collection.publicOrderedById(
|
||||
Collections.Outbox,
|
||||
collectionId,
|
||||
page,
|
||||
null,
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
static addFollower(owningUser, followingActor, ignoreDupes, cb) {
|
||||
const collectionId = Endpoints.followers(owningUser);
|
||||
return Collection.addToCollection(
|
||||
Collections.Followers,
|
||||
owningUser,
|
||||
collectionId,
|
||||
followingActor.id, // Actor following owningUser
|
||||
followingActor,
|
||||
false, // we'll check dynamically when queried
|
||||
ignoreDupes,
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
static addFollowRequest(owningUser, requestActivity, cb) {
|
||||
const collectionId = Endpoints.makeUserUrl(owningUser) + '/follow-requests';
|
||||
return Collection.addToCollection(
|
||||
Collections.FollowRequests,
|
||||
owningUser,
|
||||
collectionId,
|
||||
requestActivity.id,
|
||||
requestActivity,
|
||||
true, // private
|
||||
true, // ignoreDupes
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
static addFollowing(owningUser, followingActor, ignoreDupes, cb) {
|
||||
const collectionId = Endpoints.following(owningUser);
|
||||
return Collection.addToCollection(
|
||||
Collections.Following,
|
||||
owningUser,
|
||||
collectionId,
|
||||
followingActor.id, // Actor owningUser is following
|
||||
followingActor,
|
||||
false, // we'll check dynamically when queried
|
||||
ignoreDupes,
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
static addOutboxItem(owningUser, outboxItem, isPrivate, ignoreDupes, cb) {
|
||||
const collectionId = Endpoints.outbox(owningUser);
|
||||
return Collection.addToCollection(
|
||||
Collections.Outbox,
|
||||
owningUser,
|
||||
collectionId,
|
||||
outboxItem.id,
|
||||
outboxItem,
|
||||
isPrivate,
|
||||
ignoreDupes,
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
static addInboxItem(inboxItem, owningUser, ignoreDupes, cb) {
|
||||
const collectionId = Endpoints.inbox(owningUser);
|
||||
return Collection.addToCollection(
|
||||
Collections.Inbox,
|
||||
owningUser,
|
||||
collectionId,
|
||||
inboxItem.id,
|
||||
inboxItem,
|
||||
true,
|
||||
ignoreDupes,
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
static addSharedInboxItem(inboxItem, ignoreDupes, cb) {
|
||||
return Collection.addToCollection(
|
||||
Collections.SharedInbox,
|
||||
null, // N/A
|
||||
PublicCollectionId,
|
||||
inboxItem.id,
|
||||
inboxItem,
|
||||
false,
|
||||
ignoreDupes,
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
// Actors is a special collection
|
||||
static actor(actorIdOrSubject, cb) {
|
||||
// We always store subjects prefixed with '@'
|
||||
if (!/^https?:\/\//.test(actorIdOrSubject) && '@' !== actorIdOrSubject[0]) {
|
||||
actorIdOrSubject = `@${actorIdOrSubject}`;
|
||||
}
|
||||
|
||||
apDb.get(
|
||||
`SELECT c.name, c.timestamp, c.owner_actor_id, c.is_private, c.object_json, m.meta_value
|
||||
FROM collection c, collection_object_meta m
|
||||
WHERE c.collection_id = ? AND c.name = ? AND m.object_id = c.object_id AND (c.object_id LIKE ? OR (m.meta_name = ? AND m.meta_value LIKE ?))
|
||||
LIMIT 1;`,
|
||||
[
|
||||
ActorCollectionId,
|
||||
Collections.Actors,
|
||||
actorIdOrSubject,
|
||||
'actor_subject',
|
||||
actorIdOrSubject,
|
||||
],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return cb(
|
||||
Errors.DoesNotExist(`No Actor found for "${actorIdOrSubject}"`)
|
||||
);
|
||||
}
|
||||
|
||||
const obj = ActivityPubObject.fromJsonString(row.object_json);
|
||||
if (!obj) {
|
||||
return cb(Errors.Invalid('Failed to parse Object JSON'));
|
||||
}
|
||||
|
||||
const info = Collection._rowToObjectInfo(row);
|
||||
if (row.meta_value) {
|
||||
info.subject = row.meta_value;
|
||||
} else {
|
||||
info.subject = obj.id;
|
||||
}
|
||||
|
||||
return cb(null, obj, info);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static addActor(actor, subject, cb) {
|
||||
async.waterfall(
|
||||
[
|
||||
callback => {
|
||||
return apDb.beginTransaction(callback);
|
||||
},
|
||||
(trans, callback) => {
|
||||
trans.run(
|
||||
`REPLACE INTO collection (collection_id, name, timestamp, owner_actor_id, object_id, object_json, is_private)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?);`,
|
||||
[
|
||||
ActorCollectionId,
|
||||
Collections.Actors,
|
||||
getISOTimestampString(),
|
||||
PublicCollectionId,
|
||||
actor.id,
|
||||
JSON.stringify(actor),
|
||||
false,
|
||||
],
|
||||
err => {
|
||||
return callback(err, trans);
|
||||
}
|
||||
);
|
||||
},
|
||||
(trans, callback) => {
|
||||
trans.run(
|
||||
`REPLACE INTO collection_object_meta (collection_id, name, object_id, meta_name, meta_value)
|
||||
VALUES(?, ?, ?, ?, ?);`,
|
||||
[
|
||||
ActorCollectionId,
|
||||
Collections.Actors,
|
||||
actor.id,
|
||||
'actor_subject',
|
||||
subject,
|
||||
],
|
||||
err => {
|
||||
return callback(err, trans);
|
||||
}
|
||||
);
|
||||
},
|
||||
],
|
||||
(err, trans) => {
|
||||
if (err) {
|
||||
trans.rollback(err => {
|
||||
return cb(err);
|
||||
});
|
||||
} else {
|
||||
trans.commit(err => {
|
||||
return cb(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static removeExpiredActors(maxAgeDays, cb) {
|
||||
apDb.run(
|
||||
`DELETE FROM collection
|
||||
WHERE collection_id = ? AND name = ? AND DATETIME(timestamp, "+${maxAgeDays} days") > DATETIME("now");`,
|
||||
[ActorCollectionId, Collections.Actors],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get Object(s) by ID; There may be multiples as they may be
|
||||
// e.g. Actors belonging to multiple followers collections.
|
||||
// This method also returns information about the objects
|
||||
// and any items that can't be parsed
|
||||
static objectsById(objectId, cb) {
|
||||
apDb.all(
|
||||
`SELECT name, timestamp, owner_actor_id, object_json, is_private
|
||||
FROM collection
|
||||
WHERE object_id = ?;`,
|
||||
[objectId],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
const results = (rows || []).map(r => {
|
||||
const info = {
|
||||
info: this._rowToObjectInfo(r),
|
||||
object: ActivityPubObject.fromJsonString(r.object_json),
|
||||
};
|
||||
if (!info.object) {
|
||||
info.raw = r.object_json;
|
||||
}
|
||||
return info;
|
||||
});
|
||||
|
||||
return cb(null, results);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static ownedObjectByNameAndId(collectionName, owningUser, objectId, cb) {
|
||||
const actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
|
||||
if (!actorId) {
|
||||
return cb(
|
||||
Errors.MissingProperty(
|
||||
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
apDb.get(
|
||||
`SELECT name, timestamp, owner_actor_id, object_json, is_private
|
||||
FROM collection
|
||||
WHERE name = ? AND owner_actor_id = ? AND object_id = ?
|
||||
LIMIT 1;`,
|
||||
[collectionName, actorId, objectId],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return cb(null, null);
|
||||
}
|
||||
|
||||
const obj = ActivityPubObject.fromJsonString(row.object_json);
|
||||
if (!obj) {
|
||||
return cb(Errors.Invalid('Failed to parse Object JSON'));
|
||||
}
|
||||
|
||||
return cb(null, obj, Collection._rowToObjectInfo(row));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static objectByNameAndId(collectionName, objectId, cb) {
|
||||
apDb.get(
|
||||
`SELECT name, timestamp, owner_actor_id, object_json, is_private
|
||||
FROM collection
|
||||
WHERE name = ? AND object_id = ?
|
||||
LIMIT 1;`,
|
||||
[collectionName, objectId],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return cb(null, null);
|
||||
}
|
||||
|
||||
const obj = ActivityPubObject.fromJsonString(row.object_json);
|
||||
if (!obj) {
|
||||
return cb(Errors.Invalid('Failed to parse Object JSON'));
|
||||
}
|
||||
|
||||
return cb(null, obj, Collection._rowToObjectInfo(row));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static objectByEmbeddedId(objectId, cb) {
|
||||
apDb.get(
|
||||
`SELECT name, timestamp, owner_actor_id, object_json, is_private
|
||||
FROM collection
|
||||
WHERE json_extract(object_json, '$.object.id') = ?
|
||||
LIMIT 1;`,
|
||||
[objectId],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
// no match
|
||||
return cb(null, null);
|
||||
}
|
||||
|
||||
const obj = ActivityPubObject.fromJsonString(row.object_json);
|
||||
if (!obj) {
|
||||
return cb(Errors.Invalid('Failed to parse Object JSON'));
|
||||
}
|
||||
|
||||
return cb(null, obj, Collection._rowToObjectInfo(row));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static publicOrderedById(collectionName, collectionId, page, mapper, cb) {
|
||||
if (!page) {
|
||||
return apDb.get(
|
||||
`SELECT COUNT(collection_id) AS count
|
||||
FROM collection
|
||||
WHERE name = ? AND collection_id = ? AND is_private = FALSE;`,
|
||||
[collectionName, collectionId],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
let obj;
|
||||
if (row.count > 0) {
|
||||
obj = {
|
||||
id: collectionId,
|
||||
type: 'OrderedCollection',
|
||||
first: `${collectionId}?page=1`,
|
||||
totalItems: row.count,
|
||||
};
|
||||
} else {
|
||||
obj = {
|
||||
id: collectionId,
|
||||
type: 'OrderedCollection',
|
||||
totalItems: 0,
|
||||
orderedItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
return cb(null, new Collection(obj));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// :TODO: actual paging...
|
||||
apDb.all(
|
||||
`SELECT object_json
|
||||
FROM collection
|
||||
WHERE name = ? AND collection_id = ? AND is_private = FALSE
|
||||
ORDER BY timestamp;`,
|
||||
[collectionName, collectionId],
|
||||
(err, entries) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
try {
|
||||
entries = (entries || []).map(e => JSON.parse(e.object_json));
|
||||
} catch (e) {
|
||||
Log.error(`Collection "${collectionId}" error: ${e.message}`);
|
||||
}
|
||||
|
||||
if (mapper && entries.length > 0) {
|
||||
entries = entries.map(mapper);
|
||||
}
|
||||
|
||||
let obj;
|
||||
if ('all' === page) {
|
||||
obj = {
|
||||
id: collectionId,
|
||||
type: 'OrderedCollection',
|
||||
totalItems: entries.length,
|
||||
orderedItems: entries,
|
||||
};
|
||||
} else {
|
||||
obj = {
|
||||
id: `${collectionId}/page=${page}`,
|
||||
type: 'OrderedCollectionPage',
|
||||
totalItems: entries.length,
|
||||
orderedItems: entries,
|
||||
partOf: collectionId,
|
||||
};
|
||||
}
|
||||
|
||||
return cb(null, new Collection(obj));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static ownedOrderedByUser(
|
||||
collectionName,
|
||||
owningUser,
|
||||
includePrivate,
|
||||
page,
|
||||
mapper,
|
||||
cb
|
||||
) {
|
||||
const privateQuery = includePrivate ? '' : ' AND is_private = FALSE';
|
||||
const actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
|
||||
if (!actorId) {
|
||||
return cb(
|
||||
Errors.MissingProperty(
|
||||
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// e.g. http://somewhere.com/_enig/ap/users/NuSkooler/followers
|
||||
const collectionId = Endpoints.makeUserUrl(owningUser) + `/${collectionName}`;
|
||||
|
||||
if (!page) {
|
||||
return apDb.get(
|
||||
`SELECT COUNT(collection_id) AS count
|
||||
FROM collection
|
||||
WHERE owner_actor_id = ? AND name = ?${privateQuery};`,
|
||||
[actorId, collectionName],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
//
|
||||
// Mastodon for instance, will never follow up for the
|
||||
// actual data from some Collections such as 'followers';
|
||||
// Instead, they only use the |totalItems| to form an
|
||||
// approximate follower count.
|
||||
//
|
||||
let obj;
|
||||
if (row.count > 0) {
|
||||
obj = {
|
||||
id: collectionId,
|
||||
type: 'OrderedCollection',
|
||||
first: `${collectionId}?page=1`,
|
||||
totalItems: row.count,
|
||||
};
|
||||
} else {
|
||||
obj = {
|
||||
id: collectionId,
|
||||
type: 'OrderedCollection',
|
||||
totalItems: 0,
|
||||
orderedItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
return cb(null, new Collection(obj));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// :TODO: actual paging...
|
||||
apDb.all(
|
||||
`SELECT object_json
|
||||
FROM collection
|
||||
WHERE owner_actor_id = ? AND name = ?${privateQuery}
|
||||
ORDER BY timestamp;`,
|
||||
[actorId, collectionName],
|
||||
(err, entries) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
try {
|
||||
entries = (entries || []).map(e => JSON.parse(e.object_json));
|
||||
} catch (e) {
|
||||
Log.error(`Collection "${collectionId}" error: ${e.message}`);
|
||||
}
|
||||
|
||||
if (mapper && entries.length > 0) {
|
||||
entries = entries.map(mapper);
|
||||
}
|
||||
|
||||
const obj = {
|
||||
id: `${collectionId}/page=${page}`,
|
||||
type: 'OrderedCollectionPage',
|
||||
totalItems: entries.length,
|
||||
orderedItems: entries,
|
||||
partOf: collectionId,
|
||||
};
|
||||
|
||||
return cb(null, new Collection(obj));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/activitypub/#update-activity-inbox
|
||||
static updateCollectionEntry(collectionName, objectId, obj, cb) {
|
||||
if (!isString(obj)) {
|
||||
obj = JSON.stringify(obj);
|
||||
}
|
||||
|
||||
apDb.run(
|
||||
`UPDATE collection
|
||||
SET object_json = ?, timestamp = ?
|
||||
WHERE name = ? AND object_id = ?;`,
|
||||
[obj, collectionName, getISOTimestampString(), objectId],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static addToCollection(
|
||||
collectionName,
|
||||
owningUser,
|
||||
collectionId,
|
||||
objectId,
|
||||
obj,
|
||||
isPrivate,
|
||||
ignoreDupes,
|
||||
cb
|
||||
) {
|
||||
if (!isString(obj)) {
|
||||
obj = JSON.stringify(obj);
|
||||
}
|
||||
|
||||
let actorId;
|
||||
if (owningUser) {
|
||||
actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
|
||||
if (!actorId) {
|
||||
return cb(
|
||||
Errors.MissingProperty(
|
||||
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
actorId = PublicCollectionId;
|
||||
}
|
||||
|
||||
isPrivate = isPrivate ? 1 : 0;
|
||||
|
||||
apDb.run(
|
||||
`INSERT OR IGNORE INTO collection (name, timestamp, collection_id, owner_actor_id, object_id, object_json, is_private)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||||
[
|
||||
collectionName,
|
||||
getISOTimestampString(),
|
||||
collectionId,
|
||||
actorId,
|
||||
objectId,
|
||||
obj,
|
||||
isPrivate,
|
||||
],
|
||||
function res(err) {
|
||||
// non-arrow for 'this' scope
|
||||
if (err && 'SQLITE_CONSTRAINT' === err.code) {
|
||||
if (ignoreDupes) {
|
||||
err = null; // ignore
|
||||
}
|
||||
return cb(err);
|
||||
}
|
||||
return cb(err, this.lastID);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static removeOwnedById(collectionName, owningUser, objectId, cb) {
|
||||
const actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
|
||||
if (!actorId) {
|
||||
return cb(
|
||||
Errors.MissingProperty(
|
||||
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
|
||||
)
|
||||
);
|
||||
}
|
||||
apDb.run(
|
||||
`DELETE FROM collection
|
||||
WHERE name = ? AND owner_actor_id = ? AND object_id = ?;`,
|
||||
[collectionName, actorId, objectId],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static removeById(collectionName, objectId, cb) {
|
||||
apDb.run(
|
||||
`DELETE FROM collection
|
||||
WHERE name = ? AND object_id = ?;`,
|
||||
[collectionName, objectId],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static removeByMaxCount(collectionName, maxCount, cb) {
|
||||
apDb.run(
|
||||
`DELETE FROM collection
|
||||
WHERE _rowid_ IN (
|
||||
SELECT _rowid_
|
||||
FROM collection
|
||||
WHERE name = ?
|
||||
ORDER BY _rowid_ DESC
|
||||
LIMIT -1 OFFSET ${maxCount}
|
||||
);`,
|
||||
[maxCount],
|
||||
function res(err) {
|
||||
// non-arrow function for 'this'
|
||||
Collection._removeByLogHelper(
|
||||
collectionName,
|
||||
'MaxCount',
|
||||
err,
|
||||
maxCount,
|
||||
this.changes
|
||||
);
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static removeByMaxAgeDays(collectionName, maxAgeDays, cb) {
|
||||
apDb.run(
|
||||
`DELETE FROM collection
|
||||
WHERE name = ? AND timestamp < DATE('now', '-${maxAgeDays} days');`,
|
||||
[maxAgeDays],
|
||||
function res(err) {
|
||||
// non-arrow function for 'this'
|
||||
Collection._removeByLogHelper(
|
||||
collectionName,
|
||||
'MaxAgeDays',
|
||||
err,
|
||||
maxAgeDays,
|
||||
this.changes
|
||||
);
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static _removeByLogHelper(collectionName, type, err, value, deletedCount) {
|
||||
if (err) {
|
||||
Log.error(
|
||||
{ collectionName, error: err.message, type, value },
|
||||
'Error trimming collection'
|
||||
);
|
||||
} else {
|
||||
Log.debug(
|
||||
{ collectionName, type, value, deletedCount },
|
||||
'Collection trimmed successfully'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static _rowToObjectInfo(row) {
|
||||
return {
|
||||
name: row.name,
|
||||
timestamp: parseTimestampOrNow(row.timestamp),
|
||||
ownerActorId: row.owner_actor_id,
|
||||
isPrivate: row.is_private,
|
||||
};
|
||||
}
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
exports.ActivityStreamsContext = 'https://www.w3.org/ns/activitystreams';
|
||||
exports.PublicCollectionId = 'https://www.w3.org/ns/activitystreams#Public';
|
||||
exports.ActivityStreamMediaType = 'application/activity+json';
|
||||
|
||||
exports.ActorCollectionId = exports.PublicCollectionId + 'Actors';
|
||||
|
||||
const WellKnownActivity = {
|
||||
Create: 'Create',
|
||||
Update: 'Update',
|
||||
Delete: 'Delete',
|
||||
Follow: 'Follow',
|
||||
Accept: 'Accept',
|
||||
Reject: 'Reject',
|
||||
Add: 'Add',
|
||||
Remove: 'Remove',
|
||||
Like: 'Like',
|
||||
Announce: 'Announce',
|
||||
Undo: 'Undo',
|
||||
Tombstone: 'Tombstone',
|
||||
};
|
||||
exports.WellKnownActivity = WellKnownActivity;
|
||||
|
||||
const WellKnownActivityTypes = Object.values(WellKnownActivity);
|
||||
exports.WellKnownActivityTypes = WellKnownActivityTypes;
|
||||
|
||||
exports.WellKnownRecipientFields = ['audience', 'bcc', 'bto', 'cc', 'to'];
|
||||
|
||||
// Signatures utilized in HTTP signature generation
|
||||
exports.HttpSignatureSignHeaders = [
|
||||
'(request-target)',
|
||||
'host',
|
||||
'date',
|
||||
'digest',
|
||||
'content-type',
|
||||
];
|
||||
|
||||
const Collections = {
|
||||
Following: 'following',
|
||||
Followers: 'followers',
|
||||
FollowRequests: 'followRequests',
|
||||
Outbox: 'outbox',
|
||||
Inbox: 'inbox',
|
||||
SharedInbox: 'sharedInbox',
|
||||
Actors: 'actors',
|
||||
};
|
||||
exports.Collections = Collections;
|
|
@ -0,0 +1,58 @@
|
|||
const { WellKnownLocations } = require('../servers/content/web');
|
||||
const { buildUrl } = require('../web_util');
|
||||
|
||||
// deps
|
||||
const { v4: UUIDv4 } = require('uuid');
|
||||
|
||||
exports.makeUserUrl = makeUserUrl;
|
||||
exports.inbox = inbox;
|
||||
exports.outbox = outbox;
|
||||
exports.followers = followers;
|
||||
exports.following = following;
|
||||
exports.actorId = actorId;
|
||||
exports.profile = profile;
|
||||
exports.avatar = avatar;
|
||||
exports.sharedInbox = sharedInbox;
|
||||
exports.objectId = objectId;
|
||||
|
||||
const ActivityPubUsersPrefix = '/ap/users/';
|
||||
|
||||
function makeUserUrl(user, relPrefix = ActivityPubUsersPrefix) {
|
||||
return buildUrl(WellKnownLocations.Internal + `${relPrefix}${user.username}`);
|
||||
}
|
||||
|
||||
function inbox(user) {
|
||||
return makeUserUrl(user, ActivityPubUsersPrefix) + '/inbox';
|
||||
}
|
||||
|
||||
function outbox(user) {
|
||||
return makeUserUrl(user, ActivityPubUsersPrefix) + '/outbox';
|
||||
}
|
||||
|
||||
function followers(user) {
|
||||
return makeUserUrl(user, ActivityPubUsersPrefix) + '/followers';
|
||||
}
|
||||
|
||||
function following(user) {
|
||||
return makeUserUrl(user, ActivityPubUsersPrefix) + '/following';
|
||||
}
|
||||
|
||||
function actorId(user) {
|
||||
return makeUserUrl(user, ActivityPubUsersPrefix);
|
||||
}
|
||||
|
||||
function profile(user) {
|
||||
return buildUrl(WellKnownLocations.Internal + `/wf/@${user.username}`);
|
||||
}
|
||||
|
||||
function avatar(user, filename) {
|
||||
return makeUserUrl(user, '/users/') + `/avatar/${filename}`;
|
||||
}
|
||||
|
||||
function sharedInbox() {
|
||||
return buildUrl(WellKnownLocations.Internal + '/ap/shared-inbox');
|
||||
}
|
||||
|
||||
function objectId(objectType) {
|
||||
return buildUrl(WellKnownLocations.Internal + `/ap/${UUIDv4()}/${objectType}`);
|
||||
}
|
|
@ -0,0 +1,184 @@
|
|||
const { Collections, WellKnownActivity } = require('./const');
|
||||
const ActivityPubObject = require('./object');
|
||||
const UserProps = require('../user_property');
|
||||
const { Errors } = require('../enig_error');
|
||||
const Collection = require('./collection');
|
||||
const Actor = require('./actor');
|
||||
const Activity = require('./activity');
|
||||
|
||||
const async = require('async');
|
||||
|
||||
exports.sendFollowRequest = sendFollowRequest;
|
||||
exports.sendUnfollowRequest = sendUnfollowRequest;
|
||||
exports.acceptFollowRequest = acceptFollowRequest;
|
||||
exports.rejectFollowRequest = rejectFollowRequest;
|
||||
|
||||
function sendFollowRequest(fromUser, toActor, cb) {
|
||||
const fromActorId = fromUser.getProperty(UserProps.ActivityPubActorId);
|
||||
if (!fromActorId) {
|
||||
return cb(
|
||||
Errors.MissingProperty(
|
||||
`User missing "${UserProps.ActivityPubActorId}" property`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// We always add to the following collection;
|
||||
// We expect an async follow up request to our server of
|
||||
// Accept or Reject but it's not guaranteed
|
||||
const followRequest = new ActivityPubObject({
|
||||
id: ActivityPubObject.makeObjectId('follow'),
|
||||
type: WellKnownActivity.Follow,
|
||||
actor: fromActorId,
|
||||
object: toActor.id,
|
||||
});
|
||||
|
||||
toActor._followRequest = followRequest;
|
||||
Collection.addFollowing(fromUser, toActor, true, err => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
return followRequest.sendTo(toActor.inbox, fromUser, cb);
|
||||
});
|
||||
}
|
||||
|
||||
function sendUnfollowRequest(fromUser, toActor, cb) {
|
||||
const fromActorId = fromUser.getProperty(UserProps.ActivityPubActorId);
|
||||
if (!fromActorId) {
|
||||
return cb(
|
||||
Errors.MissingProperty(
|
||||
`User missing "${UserProps.ActivityPubActorId}" property`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch previously saved 'Follow'; We're going to Undo it &
|
||||
// need a copy.
|
||||
Collection.ownedObjectByNameAndId(
|
||||
Collections.Following,
|
||||
fromUser,
|
||||
toActor.id,
|
||||
(err, followedActor) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
// Always remove from the local collection, notify the remote server
|
||||
Collection.removeOwnedById(
|
||||
Collections.Following,
|
||||
fromUser,
|
||||
toActor.id,
|
||||
err => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
const undoRequest = new ActivityPubObject({
|
||||
id: ActivityPubObject.makeObjectId('undo'),
|
||||
type: WellKnownActivity.Undo,
|
||||
actor: fromActorId,
|
||||
object: followedActor._followRequest,
|
||||
});
|
||||
|
||||
return undoRequest.sendTo(toActor.inbox, fromUser, cb);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function acceptFollowRequest(localUser, remoteActor, requestActivity, cb) {
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return Collection.addFollower(
|
||||
localUser,
|
||||
remoteActor,
|
||||
true, // ignore dupes
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
Actor.fromLocalUser(localUser, (err, localActor) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const accept = Activity.makeAccept(localActor.id, requestActivity);
|
||||
|
||||
accept.sendTo(remoteActor.inbox, localUser, (err, respBody, res) => {
|
||||
if (err) {
|
||||
return callback(Errors.HttpError(err.message, err.code));
|
||||
}
|
||||
|
||||
if (res.statusCode !== 202 && res.statusCode !== 200) {
|
||||
return callback(
|
||||
Errors.HttpError(
|
||||
`Unexpected HTTP status code ${res.statusCode}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return callback(null);
|
||||
});
|
||||
});
|
||||
},
|
||||
callback => {
|
||||
// remove from local requests Collection
|
||||
return Collection.removeOwnedById(
|
||||
Collections.FollowRequests,
|
||||
localUser,
|
||||
requestActivity.id,
|
||||
callback
|
||||
);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function rejectFollowRequest(localUser, requestActor, requestActivity, cb) {
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
Actor.fromLocalUser(localUser, (err, localActor) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const reject = Activity.makeReject(localActor, localActor);
|
||||
reject.sendTo(requestActor.inbox, localUser, (err, respBody, res) => {
|
||||
if (err) {
|
||||
return callback(Errors.HttpError(err.message, err.code));
|
||||
}
|
||||
|
||||
if (res.statusCode !== 202 && res.statusCode !== 200) {
|
||||
return callback(
|
||||
Errors.HttpError(
|
||||
`Unexpected HTTP status code ${res.statusCode}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return callback(null);
|
||||
});
|
||||
});
|
||||
},
|
||||
callback => {
|
||||
// remove from local requests Collection
|
||||
return Collection.removeOwnedById(
|
||||
Collections.FollowRequests,
|
||||
localUser,
|
||||
requestActivity.id,
|
||||
callback
|
||||
);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,433 @@
|
|||
const Message = require('../message');
|
||||
const ActivityPubObject = require('./object');
|
||||
const { Errors } = require('../enig_error');
|
||||
const { getISOTimestampString } = require('../database');
|
||||
const User = require('../user');
|
||||
const {
|
||||
parseTimestampOrNow,
|
||||
messageToHtml,
|
||||
htmlToMessageBody,
|
||||
recipientIdsFromObject,
|
||||
} = require('./util');
|
||||
const { PublicCollectionId } = require('./const');
|
||||
const { isAnsi } = require('../string_util');
|
||||
|
||||
// deps
|
||||
const { v5: UUIDv5 } = require('uuid');
|
||||
const Actor = require('./actor');
|
||||
const Collection = require('./collection');
|
||||
const async = require('async');
|
||||
const { isString, isObject, truncate } = require('lodash');
|
||||
|
||||
const PublicMessageIdNamespace = 'a26ae389-5dfb-4b24-a58e-5472085c8e42';
|
||||
const APDefaultSummary = '[No Subject]';
|
||||
|
||||
module.exports = class Note extends ActivityPubObject {
|
||||
constructor(obj) {
|
||||
super(obj, null); // Note are wrapped
|
||||
}
|
||||
|
||||
isValid() {
|
||||
if (!super.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.type !== 'Note') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// :TODO: validate required properties
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
recipientIds() {
|
||||
return recipientIdsFromObject(this);
|
||||
}
|
||||
|
||||
static fromPublicNoteId(noteId, cb) {
|
||||
Collection.objectByEmbeddedId(noteId, (err, obj, objInfo) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (!obj) {
|
||||
return cb(null, null);
|
||||
}
|
||||
|
||||
if (objInfo.isPrivate || !obj.object || obj.object.type !== 'Note') {
|
||||
return cb(null, null);
|
||||
}
|
||||
|
||||
return cb(null, new Note(obj.object));
|
||||
});
|
||||
}
|
||||
|
||||
// A local Message bound for ActivityPub
|
||||
static fromLocalMessage(message, webServer, cb) {
|
||||
const localUserId = message.getLocalFromUserId();
|
||||
if (!localUserId) {
|
||||
return cb(Errors.UnexpectedState('Invalid user ID for local user!'));
|
||||
}
|
||||
|
||||
const remoteActorAccount = message.getRemoteToUser();
|
||||
if (!remoteActorAccount && message.isPrivate()) {
|
||||
return cb(
|
||||
Errors.UnexpectedState('Message does not contain a remote address')
|
||||
);
|
||||
}
|
||||
|
||||
async.waterfall(
|
||||
[
|
||||
callback => {
|
||||
return User.getUser(localUserId, callback);
|
||||
},
|
||||
(fromUser, callback) => {
|
||||
Actor.fromLocalUser(fromUser, (err, fromActor) => {
|
||||
return callback(err, fromUser, fromActor);
|
||||
});
|
||||
},
|
||||
(fromUser, fromActor, callback) => {
|
||||
if (message.isPrivate()) {
|
||||
Actor.fromId(remoteActorAccount, (err, remoteActor) => {
|
||||
return callback(err, fromUser, fromActor, remoteActor);
|
||||
});
|
||||
} else {
|
||||
return callback(null, fromUser, fromActor, null);
|
||||
}
|
||||
},
|
||||
(fromUser, fromActor, remoteActor, callback) => {
|
||||
if (!message.replyToMsgId) {
|
||||
return callback(null, null, fromUser, fromActor, remoteActor);
|
||||
}
|
||||
|
||||
Message.getMetaValuesByMessageId(
|
||||
message.replyToMsgId,
|
||||
Message.WellKnownMetaCategories.ActivityPub,
|
||||
Message.ActivityPubPropertyNames.NoteId,
|
||||
(err, replyToNoteId) => {
|
||||
// (ignore error)
|
||||
return callback(
|
||||
null,
|
||||
replyToNoteId,
|
||||
fromUser,
|
||||
fromActor,
|
||||
remoteActor
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
(replyToNoteId, fromUser, fromActor, remoteActor, callback) => {
|
||||
const to = [
|
||||
message.isPrivate() ? remoteActor.id : PublicCollectionId,
|
||||
];
|
||||
|
||||
const sourceMediaType = isAnsi(message.message)
|
||||
? 'text/x-ansi' // ye ol' https://lists.freedesktop.org/archives/xdg/2006-March/006214.html
|
||||
: 'text/plain';
|
||||
|
||||
// https://docs.joinmastodon.org/spec/activitypub/#properties-used
|
||||
const obj = {
|
||||
id: ActivityPubObject.makeObjectId('note'),
|
||||
type: 'Note',
|
||||
published: getISOTimestampString(message.modTimestamp),
|
||||
to,
|
||||
attributedTo: fromActor.id,
|
||||
summary: message.subject.trim(),
|
||||
content: messageToHtml(message),
|
||||
source: {
|
||||
content: message.message,
|
||||
mediaType: sourceMediaType,
|
||||
},
|
||||
sensitive: message.subject.startsWith('[NSFW]'),
|
||||
};
|
||||
|
||||
if (replyToNoteId) {
|
||||
obj.inReplyTo = replyToNoteId;
|
||||
}
|
||||
|
||||
const note = new Note(obj);
|
||||
const context = ActivityPubObject.makeContext([], {
|
||||
sensitive: 'as:sensitive',
|
||||
});
|
||||
return callback(null, {
|
||||
note,
|
||||
fromUser,
|
||||
remoteActor,
|
||||
context,
|
||||
});
|
||||
},
|
||||
],
|
||||
(err, noteInfo) => {
|
||||
return cb(err, noteInfo);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toMessage(options, cb) {
|
||||
if (!options.toUser || !isString(options.areaTag)) {
|
||||
return cb(Errors.MissingParam('Missing one or more required options!'));
|
||||
}
|
||||
|
||||
const isPrivate = isObject(options.toUser);
|
||||
|
||||
//
|
||||
// Message UUIDs are unique in the message database;
|
||||
// However, we may need to deliver a particular message to:
|
||||
// - #Public / sharedInbox
|
||||
// - 1:N private user inboxes
|
||||
//
|
||||
// In both cases, the UUID is stable. That is, the same ID
|
||||
// will equal the same UUID as to prevent dupes.
|
||||
//
|
||||
const makeMessageUuid = () => {
|
||||
if (isPrivate) {
|
||||
// UUID specific to the target user
|
||||
const url = `${this.id}/${options.toUser.userId}`;
|
||||
return UUIDv5(url, UUIDv5.URL);
|
||||
} else {
|
||||
return UUIDv5(this.id, PublicMessageIdNamespace);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the remote actor info to get their user info
|
||||
Actor.fromId(this.attributedTo, (err, attributedToActor, fromActorSubject) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
const message = new Message({
|
||||
uuid: makeMessageUuid(),
|
||||
});
|
||||
|
||||
message.fromUserName = fromActorSubject || this.attributedTo;
|
||||
|
||||
// :TODO: it would be better to do some basic HTML to ANSI or pipe codes perhaps
|
||||
message.message = htmlToMessageBody(
|
||||
// try to handle various implementations
|
||||
// - https://docs.joinmastodon.org/spec/activitypub/#payloads
|
||||
// - https://indieweb.org/post-type-discovery#Algorithm
|
||||
this.content || this.name || this.summary
|
||||
);
|
||||
|
||||
this._setToUserName(message, isPrivate, options.toUser);
|
||||
this._setSubject(message);
|
||||
|
||||
message.areaTag = options.areaTag || Message.WellKnownAreaTags.Private;
|
||||
|
||||
// List all attachments
|
||||
if (Array.isArray(this.attachment) && this.attachment.length > 0) {
|
||||
let attachmentInfoLines = ['--[Attachments]--'];
|
||||
// https://socialhub.activitypub.rocks/t/representing-images/624
|
||||
this.attachment.forEach(att => {
|
||||
const type = att.mediaType.substring(0, att.mediaType.indexOf('/'));
|
||||
switch (type) {
|
||||
case 'image':
|
||||
{
|
||||
let imgInfo;
|
||||
if (att.height && att.width) {
|
||||
imgInfo = `Image (${att.width}x${att.height})`;
|
||||
} else {
|
||||
imgInfo = 'Image';
|
||||
}
|
||||
attachmentInfoLines.push(imgInfo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'audio':
|
||||
attachmentInfoLines.push('Audio');
|
||||
break;
|
||||
|
||||
case 'video':
|
||||
attachmentInfoLines.push('Video');
|
||||
break;
|
||||
|
||||
default:
|
||||
attachmentInfoLines.push(att.mediaType);
|
||||
}
|
||||
|
||||
if (att.name) {
|
||||
attachmentInfoLines.push(att.name);
|
||||
}
|
||||
|
||||
attachmentInfoLines.push(att.url);
|
||||
attachmentInfoLines.push('');
|
||||
attachmentInfoLines.push('');
|
||||
});
|
||||
|
||||
message.message += '\r\n\r\n' + attachmentInfoLines.join('\r\n');
|
||||
}
|
||||
|
||||
// If the Note is marked sensitive, prefix the subject
|
||||
if (this.sensitive && message.subject.indexOf('[NSFW]') === -1) {
|
||||
message.subject = `[NSFW] ${message.subject}`;
|
||||
}
|
||||
|
||||
message.modTimestamp = parseTimestampOrNow(this.published);
|
||||
|
||||
message.setRemoteFromUser(this.attributedTo);
|
||||
message.setExternalFlavor(Message.AddressFlavor.ActivityPub);
|
||||
|
||||
message.meta.ActivityPub = message.meta.ActivityPub || {};
|
||||
message.meta.ActivityPub[Message.ActivityPubPropertyNames.ActivityId] =
|
||||
options.activityId || 0;
|
||||
message.meta.ActivityPub[Message.ActivityPubPropertyNames.NoteId] = this.id;
|
||||
|
||||
if (this.inReplyTo) {
|
||||
message.meta.ActivityPub[Message.ActivityPubPropertyNames.InReplyTo] =
|
||||
this.inReplyTo;
|
||||
|
||||
const filter = {
|
||||
resultType: 'id',
|
||||
metaTuples: [
|
||||
{
|
||||
category: Message.WellKnownMetaCategories.ActivityPub,
|
||||
name: Message.ActivityPubPropertyNames.InReplyTo,
|
||||
value: this.inReplyTo,
|
||||
},
|
||||
],
|
||||
limit: 1,
|
||||
};
|
||||
Message.findMessages(filter, (err, messageId) => {
|
||||
if (messageId) {
|
||||
// we get an array, but limited 1; use the first
|
||||
messageId = messageId[0];
|
||||
message.replyToMsgId = messageId;
|
||||
}
|
||||
|
||||
return cb(null, message);
|
||||
});
|
||||
} else {
|
||||
return cb(null, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toUpdatedMessage(options, cb) {
|
||||
const original = new Message();
|
||||
original.load({ uuid: options.messageUuid }, err => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
// rebuild message
|
||||
options.areaTag = original.areaTag;
|
||||
|
||||
async.waterfall(
|
||||
[
|
||||
callback => {
|
||||
if (!original.isPrivate()) {
|
||||
options.toUser = 'All';
|
||||
return callback(null);
|
||||
}
|
||||
|
||||
const userId =
|
||||
original.meta.System[Message.SystemMetaNames.LocalToUserID];
|
||||
if (!userId) {
|
||||
return cb(
|
||||
Errors.MissingProperty(
|
||||
`User is missing "${Message.SystemMetaNames.LocalToUserID}" property`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
User.getUser(userId, (err, user) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
options.toUser = user;
|
||||
return callback(null);
|
||||
});
|
||||
},
|
||||
callback => {
|
||||
this.toMessage(options, (err, message) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
// re-target as message to be updated
|
||||
message.messageUuid = original.messageUuid;
|
||||
|
||||
return callback(null, message);
|
||||
});
|
||||
},
|
||||
],
|
||||
(err, message) => {
|
||||
return cb(err, message);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static deleteAssocMessage(noteId, cb) {
|
||||
const filter = {
|
||||
resultType: 'uuid',
|
||||
metaTuples: [
|
||||
{
|
||||
category: Message.WellKnownMetaCategories.ActivityPub,
|
||||
name: Message.ActivityPubPropertyNames.NoteId,
|
||||
value: noteId,
|
||||
},
|
||||
],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
return Message.findMessages(filter, (err, messageUuid) => {
|
||||
if (!messageUuid) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
messageUuid = messageUuid[0]; // limit 1
|
||||
|
||||
Message.deleteByMessageUuid(messageUuid, err => {
|
||||
return cb(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_setSubject(message) {
|
||||
if (this.summary) {
|
||||
message.subject = this.summary.trim();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.name) {
|
||||
message.subject = this.name.trim();
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Build a subject from the message itself:
|
||||
// - First few characters of the message, removing the @username
|
||||
// prefix, if any
|
||||
// - Truncate at the first line feed, the end of the message,
|
||||
// or 32 characters in length, whichever comes first
|
||||
// - If not end of string, we'll sub in '...'
|
||||
//
|
||||
let subject = message.message.replace(/^@[^ ,]+ /, '').trim();
|
||||
const m = /^(.+)\r?\n/.exec(subject);
|
||||
if (m && m[1]) {
|
||||
subject = m[1];
|
||||
}
|
||||
|
||||
subject = truncate(subject, { length: 32, omission: '...' });
|
||||
subject = subject || APDefaultSummary;
|
||||
message.subject = subject;
|
||||
}
|
||||
|
||||
_setToUserName(message, isPrivate, toUser) {
|
||||
if (isPrivate) {
|
||||
message.toUserName = toUser.username;
|
||||
message.meta.System[Message.SystemMetaNames.LocalToUserID] = toUser.userId;
|
||||
return;
|
||||
}
|
||||
|
||||
const m = /^(@[^ ,]+) ./.exec(message.message);
|
||||
if (m && m[1]) {
|
||||
message.toUserName = m[1];
|
||||
}
|
||||
|
||||
message.toUserName = message.toUserName || 'All';
|
||||
}
|
||||
};
|
|
@ -0,0 +1,115 @@
|
|||
const {
|
||||
ActivityStreamsContext,
|
||||
ActivityStreamMediaType,
|
||||
HttpSignatureSignHeaders,
|
||||
} = require('./const');
|
||||
const Endpoints = require('./endpoint');
|
||||
const UserProps = require('../user_property');
|
||||
const { Errors } = require('../enig_error');
|
||||
const { postJson } = require('../http_util');
|
||||
|
||||
// deps
|
||||
const { isString, isObject, isEmpty } = require('lodash');
|
||||
|
||||
const Context = '@context';
|
||||
|
||||
module.exports = class ActivityPubObject {
|
||||
constructor(obj, withContext = [ActivityStreamsContext]) {
|
||||
if (withContext) {
|
||||
this.setContext(withContext);
|
||||
}
|
||||
Object.assign(this, obj);
|
||||
}
|
||||
|
||||
static get DefaultContext() {
|
||||
return [ActivityStreamsContext];
|
||||
}
|
||||
|
||||
static makeContext(namespaceUrls, aliases = null) {
|
||||
const context = [ActivityStreamsContext];
|
||||
if (Array.isArray(namespaceUrls)) {
|
||||
context.push(...namespaceUrls);
|
||||
}
|
||||
if (isObject(aliases)) {
|
||||
context.push(aliases);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
static fromJsonString(s) {
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(s);
|
||||
obj = new ActivityPubObject(obj);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
isValid() {
|
||||
//
|
||||
// If @context is present, it must be valid;
|
||||
// child objects generally inherit, so they may not have one
|
||||
//
|
||||
if (this[Context]) {
|
||||
if (!this.isContextValid()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const checkString = s => isString(s) && s.length > 1;
|
||||
return checkString(this.id) && checkString(this.type);
|
||||
}
|
||||
|
||||
isContextValid() {
|
||||
if (Array.isArray(this[Context])) {
|
||||
if (this[Context][0] === ActivityStreamsContext) {
|
||||
return true;
|
||||
}
|
||||
} else if (isString(this[Context])) {
|
||||
if (ActivityStreamsContext === this[Context]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
setContext(context) {
|
||||
if (!Array.isArray(context)) {
|
||||
context = [context];
|
||||
}
|
||||
this['@context'] = context;
|
||||
}
|
||||
|
||||
static makeObjectId(objectType) {
|
||||
return Endpoints.objectId(objectType);
|
||||
}
|
||||
|
||||
sendTo(inboxEndpoint, fromUser, cb) {
|
||||
const privateKey = fromUser.getProperty(UserProps.PrivateActivityPubSigningKey);
|
||||
if (isEmpty(privateKey)) {
|
||||
return cb(
|
||||
Errors.MissingProperty(
|
||||
`User "${fromUser.username}" is missing the '${UserProps.PrivateActivityPubSigningKey}' property`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const reqOpts = {
|
||||
headers: {
|
||||
'Content-Type': ActivityStreamMediaType,
|
||||
},
|
||||
sign: {
|
||||
key: privateKey,
|
||||
keyId: Endpoints.actorId(fromUser) + '#main-key',
|
||||
authorizationHeaderName: 'Signature',
|
||||
headers: HttpSignatureSignHeaders,
|
||||
},
|
||||
};
|
||||
|
||||
const activityJson = JSON.stringify(this);
|
||||
return postJson(inboxEndpoint, activityJson, reqOpts, cb);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,61 @@
|
|||
const UserProps = require('../user_property');
|
||||
const Config = require('../config').get;
|
||||
|
||||
module.exports = class ActivityPubSettings {
|
||||
constructor(obj) {
|
||||
this.enabled = true;
|
||||
this.manuallyApproveFollowers = false;
|
||||
this.hideSocialGraph = false; // followers, following
|
||||
this.showRealName = true;
|
||||
this.image = '';
|
||||
this.icon = '';
|
||||
|
||||
// override default with any op config
|
||||
Object.assign(this, Config().users.activityPub);
|
||||
|
||||
// finally override with any explicit values given to us
|
||||
if (obj) {
|
||||
Object.assign(this, obj);
|
||||
}
|
||||
}
|
||||
|
||||
static fromUser(user) {
|
||||
if (!user.activityPubSettings) {
|
||||
const settingsProp = user.getProperty(UserProps.ActivityPubSettings);
|
||||
let settings;
|
||||
try {
|
||||
const parsed = JSON.parse(settingsProp);
|
||||
settings = new ActivityPubSettings(parsed);
|
||||
} catch (e) {
|
||||
settings = new ActivityPubSettings();
|
||||
}
|
||||
|
||||
user.activityPubSettings = settings;
|
||||
}
|
||||
|
||||
return user.activityPubSettings;
|
||||
}
|
||||
|
||||
persistToUserProperties(user, cb) {
|
||||
return user.persistProperty(
|
||||
UserProps.ActivityPubSettings,
|
||||
JSON.stringify(this),
|
||||
err => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
// drop from cache - force re-cache
|
||||
delete user.activityPubSettings;
|
||||
|
||||
const { prepareLocalUserAsActor } = require('./util');
|
||||
prepareLocalUserAsActor(user, { force: false }, err => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
return user.persistProperties(user.properties, cb);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,582 @@
|
|||
const { MenuModule } = require('../menu_module');
|
||||
const Collection = require('./collection');
|
||||
const { getServer } = require('../listening_server');
|
||||
const Endpoints = require('./endpoint');
|
||||
const Actor = require('./actor');
|
||||
const stringFormat = require('../string_format');
|
||||
const { pipeToAnsi } = require('../color_codes');
|
||||
const MultiLineEditTextView =
|
||||
require('../multi_line_edit_text_view').MultiLineEditTextView;
|
||||
const {
|
||||
sendFollowRequest,
|
||||
sendUnfollowRequest,
|
||||
acceptFollowRequest,
|
||||
rejectFollowRequest,
|
||||
} = require('./follow_util');
|
||||
const { Collections } = require('./const');
|
||||
const EnigAssert = require('../enigma_assert');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
const { get, cloneDeep } = require('lodash');
|
||||
const { htmlToMessageBody } = require('./util');
|
||||
|
||||
exports.moduleInfo = {
|
||||
name: 'ActivityPub Social Manager',
|
||||
desc: 'Manages ActivityPub Actors the current user is following or being followed by.',
|
||||
author: 'NuSkooler',
|
||||
};
|
||||
|
||||
const FormIds = {
|
||||
main: 0,
|
||||
};
|
||||
|
||||
const MciViewIds = {
|
||||
main: {
|
||||
actorList: 1,
|
||||
selectedActorInfo: 2,
|
||||
navMenu: 3,
|
||||
|
||||
customRangeStart: 10,
|
||||
},
|
||||
};
|
||||
|
||||
exports.getModule = class activityPubSocialManager extends MenuModule {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.setConfigWithExtraArgs(options);
|
||||
|
||||
this.followingActors = [];
|
||||
this.followerActors = [];
|
||||
this.followRequests = [];
|
||||
this.currentCollection = Collections.Following;
|
||||
this.currentHelpText = '';
|
||||
|
||||
this.menuMethods = {
|
||||
actorListKeyPressed: (formData, extraArgs, cb) => {
|
||||
const collection = this.currentCollection;
|
||||
switch (formData.key.name) {
|
||||
case 'space':
|
||||
{
|
||||
if (collection === Collections.Following) {
|
||||
return this._toggleFollowing(cb);
|
||||
}
|
||||
if (collection === Collections.FollowRequests) {
|
||||
return this._acceptFollowRequest(cb);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
{
|
||||
if (collection === Collections.Followers) {
|
||||
return this._removeFollower(cb);
|
||||
}
|
||||
|
||||
if (collection === Collections.FollowRequests) {
|
||||
return this._rejectFollowRequest(cb);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return cb(null);
|
||||
},
|
||||
listKeyPressed: (formData, extraArgs, cb) => {
|
||||
const actorListView = this.getView('main', MciViewIds.main.actorList);
|
||||
if (actorListView) {
|
||||
const keyName = get(formData, 'key.name');
|
||||
switch (keyName) {
|
||||
case 'down arrow':
|
||||
actorListView.focusNext();
|
||||
break;
|
||||
case 'up arrow':
|
||||
actorListView.focusPrevious();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return cb(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
initSequence() {
|
||||
this.webServer = getServer('codes.l33t.enigma.web.server');
|
||||
if (!this.webServer) {
|
||||
this.client.log('Could not get Web server');
|
||||
return this.prevMenu();
|
||||
}
|
||||
this.webServer = this.webServer.instance;
|
||||
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return this.beforeArt(callback);
|
||||
},
|
||||
callback => {
|
||||
return this._displayMainPage(callback);
|
||||
},
|
||||
],
|
||||
() => {
|
||||
this.finishedLoading();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_displayMainPage(cb) {
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return this.displayArtAndPrepViewController(
|
||||
'main',
|
||||
FormIds.main,
|
||||
{ clearScreen: true },
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
return this.validateMCIByViewIds(
|
||||
'main',
|
||||
Object.values(MciViewIds.main).filter(
|
||||
id => id !== MciViewIds.main.customRangeStart
|
||||
),
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
return this._populateActorLists(callback);
|
||||
},
|
||||
callback => {
|
||||
const v = id => this.getView('main', id);
|
||||
|
||||
const actorListView = v(MciViewIds.main.actorList);
|
||||
const selectedActorInfoView = v(MciViewIds.main.selectedActorInfo);
|
||||
const navMenuView = v(MciViewIds.main.navMenu);
|
||||
|
||||
// We start with following
|
||||
this._switchTo(Collections.Following);
|
||||
|
||||
actorListView.on('index update', index => {
|
||||
const selectedActor = this._getSelectedActorItem(index);
|
||||
this._updateSelectedActorInfo(
|
||||
selectedActorInfoView,
|
||||
selectedActor
|
||||
);
|
||||
});
|
||||
|
||||
navMenuView.on('index update', index => {
|
||||
const collectionName = [
|
||||
Collections.Following,
|
||||
Collections.Followers,
|
||||
Collections.FollowRequests,
|
||||
][index];
|
||||
this._switchTo(collectionName);
|
||||
});
|
||||
|
||||
return callback(null);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_switchTo(collectionName) {
|
||||
this.currentCollection = collectionName;
|
||||
const actorListView = this.getView('main', MciViewIds.main.actorList);
|
||||
|
||||
let list;
|
||||
switch (collectionName) {
|
||||
case Collections.Following:
|
||||
list = this.followingActors;
|
||||
this.currentHelpText =
|
||||
this.config.helpTextFollowing || 'SPC = Toggle Follower';
|
||||
break;
|
||||
case Collections.Followers:
|
||||
list = this.followerActors;
|
||||
this.currentHelpText =
|
||||
this.config.helpTextFollowers || 'DEL = Remove Follower';
|
||||
break;
|
||||
case Collections.FollowRequests:
|
||||
list = this.followRequests;
|
||||
this.currentHelpText =
|
||||
this.config.helpTextFollowRequests || 'SPC = Accept\r\nDEL = Deny';
|
||||
break;
|
||||
}
|
||||
EnigAssert(list);
|
||||
|
||||
actorListView.setItems(list);
|
||||
actorListView.redraw();
|
||||
|
||||
const selectedActor = this._getSelectedActorItem(
|
||||
actorListView.getFocusItemIndex()
|
||||
);
|
||||
const selectedActorInfoView = this.getView(
|
||||
'main',
|
||||
MciViewIds.main.selectedActorInfo
|
||||
);
|
||||
if (selectedActor) {
|
||||
this._updateSelectedActorInfo(selectedActorInfoView, selectedActor);
|
||||
} else {
|
||||
selectedActorInfoView.setText('');
|
||||
this.updateCustomViewTextsWithFilter(
|
||||
'main',
|
||||
MciViewIds.main.customRangeStart,
|
||||
this._getCustomInfoFormatObject(null),
|
||||
{ pipeSupport: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_getSelectedActorItem(index) {
|
||||
switch (this.currentCollection) {
|
||||
case Collections.Following:
|
||||
return this.followingActors[index];
|
||||
case Collections.Followers:
|
||||
return this.followerActors[index];
|
||||
case Collections.FollowRequests:
|
||||
return this.followRequests[index];
|
||||
}
|
||||
}
|
||||
|
||||
_getCurrentActorList() {
|
||||
return this.currentCollection === Collections.Following
|
||||
? this.followingActors
|
||||
: this.followerActors;
|
||||
}
|
||||
|
||||
_updateSelectedActorInfo(view, actorInfo) {
|
||||
if (actorInfo) {
|
||||
const selectedActorInfoFormat =
|
||||
this.config.selectedActorInfoFormat || '{text}';
|
||||
|
||||
const s = stringFormat(selectedActorInfoFormat, actorInfo);
|
||||
|
||||
if (view instanceof MultiLineEditTextView) {
|
||||
const opts = {
|
||||
prepped: false,
|
||||
forceLineTerm: true,
|
||||
};
|
||||
view.setAnsi(pipeToAnsi(s, this.client), opts);
|
||||
} else {
|
||||
view.setText(s);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCustomViewTextsWithFilter(
|
||||
'main',
|
||||
MciViewIds.main.customRangeStart,
|
||||
this._getCustomInfoFormatObject(actorInfo),
|
||||
{ pipeSupport: true }
|
||||
);
|
||||
}
|
||||
|
||||
_toggleFollowing(cb) {
|
||||
const actorListView = this.getView('main', MciViewIds.main.actorList);
|
||||
const selectedActor = this._getSelectedActorItem(
|
||||
actorListView.getFocusItemIndex()
|
||||
);
|
||||
if (selectedActor) {
|
||||
selectedActor.status = !selectedActor.status;
|
||||
selectedActor.statusIndicator = this._getStatusIndicator(
|
||||
selectedActor.status
|
||||
);
|
||||
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
if (Collections.Following === this.currentCollection) {
|
||||
return this._followingActorToggled(selectedActor, callback);
|
||||
} else {
|
||||
return this._followerActorToggled(selectedActor, callback);
|
||||
}
|
||||
},
|
||||
],
|
||||
err => {
|
||||
if (err) {
|
||||
this.client.log.error(
|
||||
{ error: err.message, type: this.currentCollection },
|
||||
`Failed to toggle "${this.currentCollection}" status`
|
||||
);
|
||||
}
|
||||
|
||||
// :TODO: we really need updateItem() call on MenuView
|
||||
actorListView.setItems(this._getCurrentActorList());
|
||||
actorListView.redraw(); // oof
|
||||
|
||||
return cb(null);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_removeSelectedFollowRequest(actorListView, moveToFollowers) {
|
||||
const followingActor = this.followRequests.splice(
|
||||
actorListView.getFocusItemIndex(),
|
||||
1
|
||||
)[0];
|
||||
|
||||
if (moveToFollowers) {
|
||||
this.followerActors.push(followingActor);
|
||||
}
|
||||
|
||||
this._switchTo(this.currentCollection); // redraw
|
||||
}
|
||||
|
||||
_acceptFollowRequest(cb) {
|
||||
EnigAssert(Collections.FollowRequests === this.currentCollection);
|
||||
|
||||
const actorListView = this.getView('main', MciViewIds.main.actorList);
|
||||
const selectedActor = this._getSelectedActorItem(
|
||||
actorListView.getFocusItemIndex()
|
||||
);
|
||||
|
||||
if (!selectedActor) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
const request = selectedActor.request;
|
||||
EnigAssert(request);
|
||||
|
||||
acceptFollowRequest(this.client.user, selectedActor, request, err => {
|
||||
if (err) {
|
||||
this.client.log.error(
|
||||
{ error: err.message },
|
||||
'Error Accepting Follow request'
|
||||
);
|
||||
}
|
||||
|
||||
this._removeSelectedFollowRequest(actorListView, true); // true=move to followers
|
||||
|
||||
return cb(err);
|
||||
});
|
||||
}
|
||||
|
||||
_removeFollower(cb) {
|
||||
// :TODO: Send a Undo
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
_rejectFollowRequest(cb) {
|
||||
EnigAssert(Collections.FollowRequests === this.currentCollection);
|
||||
|
||||
const actorListView = this.getView('main', MciViewIds.main.actorList);
|
||||
const selectedActor = this._getSelectedActorItem(
|
||||
actorListView.getFocusItemIndex()
|
||||
);
|
||||
|
||||
if (!selectedActor) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
const request = selectedActor.request;
|
||||
EnigAssert(request);
|
||||
|
||||
rejectFollowRequest(this.client.user, selectedActor, request, err => {
|
||||
if (err) {
|
||||
this.client.log.error(
|
||||
{ error: err.message },
|
||||
'Error Rejecting Follow request'
|
||||
);
|
||||
}
|
||||
|
||||
this._removeSelectedFollowRequest(actorListView, false); // false=do not move to followers
|
||||
|
||||
return cb(err);
|
||||
});
|
||||
}
|
||||
|
||||
_followingActorToggled(actorInfo, cb) {
|
||||
// Local user/Actor wants to follow or un-follow
|
||||
const wantsToFollow = actorInfo.status;
|
||||
const actor = this._actorInfoToActor(actorInfo);
|
||||
|
||||
return wantsToFollow
|
||||
? sendFollowRequest(this.client.user, actor, cb)
|
||||
: sendUnfollowRequest(this.client.user, actor, cb);
|
||||
}
|
||||
|
||||
_actorInfoToActor(actorInfo) {
|
||||
const actor = cloneDeep(actorInfo);
|
||||
|
||||
// nuke our added properties
|
||||
delete actor.subject;
|
||||
delete actor.text;
|
||||
delete actor.status;
|
||||
delete actor.statusIndicator;
|
||||
delete actor.plainTextSummary;
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
_followerActorToggled(actorInfo, cb) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
_getCustomInfoFormatObject(actorInfo) {
|
||||
const formatObj = {
|
||||
followingCount: this.followingActors.length,
|
||||
followerCount: this.followerActors.length,
|
||||
};
|
||||
|
||||
const v = f => {
|
||||
return actorInfo ? actorInfo[f] || '' : '';
|
||||
};
|
||||
|
||||
Object.assign(formatObj, {
|
||||
selectedActorId: v('id'),
|
||||
selectedActorSubject: v('subject'),
|
||||
selectedActorType: v('type'),
|
||||
selectedActorName: v('name'),
|
||||
selectedActorSummary: v('summary'),
|
||||
selectedActorPlainTextSummary: actorInfo
|
||||
? htmlToMessageBody(actorInfo.summary || '')
|
||||
: '',
|
||||
selectedActorPreferredUsername: v('preferredUsername'),
|
||||
selectedActorUrl: v('url'),
|
||||
selectedActorImage: v('image'),
|
||||
selectedActorIcon: v('icon'),
|
||||
selectedActorStatus: actorInfo ? actorInfo.status : false,
|
||||
selectedActorStatusIndicator: v('statusIndicator'),
|
||||
text: v('name'),
|
||||
helpText: this.currentHelpText,
|
||||
});
|
||||
|
||||
return formatObj;
|
||||
}
|
||||
|
||||
_getStatusIndicator(enabled) {
|
||||
return enabled
|
||||
? this.config.statusFollowing || '√'
|
||||
: this.config.statusNotFollowing || 'X';
|
||||
}
|
||||
|
||||
_populateActorLists(cb) {
|
||||
async.waterfall(
|
||||
[
|
||||
callback => {
|
||||
return this._fetchActorList(Collections.Following, callback);
|
||||
},
|
||||
(following, callback) => {
|
||||
this._fetchActorList(Collections.Followers, (err, followers) => {
|
||||
return callback(err, following, followers);
|
||||
});
|
||||
},
|
||||
(following, followers, callback) => {
|
||||
this._fetchFollowRequestActors((err, followRequests) => {
|
||||
return callback(err, following, followers, followRequests);
|
||||
});
|
||||
},
|
||||
(following, followers, followRequests, callback) => {
|
||||
const mapper = a => {
|
||||
a.plainTextSummary = htmlToMessageBody(a.summary);
|
||||
return a;
|
||||
};
|
||||
|
||||
this.followingActors = following.map(mapper);
|
||||
this.followerActors = followers.map(mapper);
|
||||
this.followRequests = followRequests.map(mapper);
|
||||
|
||||
return callback(null);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_fetchFollowRequestActors(cb) {
|
||||
Collection.followRequests(this.client.user, 'all', (err, collection) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (!collection.orderedItems || collection.orderedItems.length < 1) {
|
||||
return cb(null, []);
|
||||
}
|
||||
|
||||
const statusIndicator = this._getStatusIndicator(false);
|
||||
|
||||
async.mapLimit(
|
||||
collection.orderedItems,
|
||||
4,
|
||||
(request, nextRequest) => {
|
||||
const actorId = request.actor;
|
||||
Actor.fromId(actorId, (err, actor, subject) => {
|
||||
if (err) {
|
||||
this.client.log.warn({ actorId }, 'Failed to retrieve Actor');
|
||||
return nextRequest(null, null);
|
||||
}
|
||||
|
||||
// Add some of our own properties
|
||||
Object.assign(actor, {
|
||||
subject,
|
||||
status: false,
|
||||
statusIndicator,
|
||||
text: actor.preferredUsername,
|
||||
request,
|
||||
});
|
||||
|
||||
return nextRequest(null, actor);
|
||||
});
|
||||
},
|
||||
(err, actorsList) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
actorsList = actorsList.filter(f => f); // drop nulls
|
||||
return cb(null, actorsList);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_fetchActorList(collectionName, cb) {
|
||||
const collectionId = Endpoints[collectionName](this.client.user);
|
||||
Collection[collectionName](collectionId, 'all', (err, collection) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (!collection.orderedItems || collection.orderedItems.length < 1) {
|
||||
return cb(null, []);
|
||||
}
|
||||
|
||||
const statusIndicator = this._getStatusIndicator(true);
|
||||
|
||||
async.mapLimit(
|
||||
collection.orderedItems,
|
||||
4,
|
||||
(actorId, nextActorId) => {
|
||||
Actor.fromId(actorId, (err, actor, subject) => {
|
||||
if (err) {
|
||||
this.client.log.warn({ actorId }, 'Failed to retrieve Actor');
|
||||
return nextActorId(null, null);
|
||||
}
|
||||
|
||||
// Add some of our own properties
|
||||
Object.assign(actor, {
|
||||
subject,
|
||||
status: true,
|
||||
statusIndicator,
|
||||
text: actor.name,
|
||||
});
|
||||
|
||||
return nextActorId(null, actor);
|
||||
});
|
||||
},
|
||||
(err, actorsList) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
actorsList = actorsList.filter(f => f); // drop nulls
|
||||
return cb(null, actorsList);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,299 @@
|
|||
const { MenuModule } = require('../menu_module');
|
||||
const ActivityPubSettings = require('./settings');
|
||||
const { Errors } = require('../enig_error');
|
||||
const { getServer } = require('../listening_server');
|
||||
const { userNameToSubject } = require('./util');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
const { get, truncate } = require('lodash');
|
||||
|
||||
exports.moduleInfo = {
|
||||
name: 'ActivityPub User Config',
|
||||
desc: 'ActivityPub User Configuration',
|
||||
author: 'NuSkooler',
|
||||
};
|
||||
|
||||
const FormIds = {
|
||||
main: 0,
|
||||
images: 1,
|
||||
};
|
||||
|
||||
const MciViewIds = {
|
||||
main: {
|
||||
enabledToggle: 1,
|
||||
manuallyApproveFollowersToggle: 2,
|
||||
hideSocialGraphToggle: 3,
|
||||
showRealNameToggle: 4,
|
||||
imageUrl: 5,
|
||||
iconUrl: 6,
|
||||
manageImagesButton: 7,
|
||||
saveOrCancel: 8,
|
||||
|
||||
customRangeStart: 10,
|
||||
},
|
||||
images: {
|
||||
imageUrl: 1,
|
||||
iconUrl: 2,
|
||||
saveOrCancel: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const EnabledViewGroup = [
|
||||
MciViewIds.main.manuallyApproveFollowersToggle,
|
||||
MciViewIds.main.hideSocialGraphToggle,
|
||||
MciViewIds.main.showRealNameToggle,
|
||||
MciViewIds.main.imageUrl,
|
||||
MciViewIds.main.iconUrl,
|
||||
MciViewIds.main.manageImagesButton,
|
||||
];
|
||||
|
||||
exports.getModule = class ActivityPubUserConfig extends MenuModule {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.setConfigWithExtraArgs(options);
|
||||
|
||||
this.menuMethods = {
|
||||
mainSubmit: (formData, extraArgs, cb) => {
|
||||
switch (formData.submitId) {
|
||||
case MciViewIds.main.manageImagesButton:
|
||||
return this._manageImagesButton(cb);
|
||||
|
||||
case MciViewIds.main.saveOrCancel: {
|
||||
const save = get(formData, 'value.saveOrCancel') === 0;
|
||||
return save ? this._save(formData.value, cb) : this.prevMenu(cb);
|
||||
}
|
||||
|
||||
default:
|
||||
cb(
|
||||
Errors.UnexpectedState(
|
||||
`Unexpected submitId: ${formData.submitId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
imagesSubmit: (formData, extraArgs, cb) => {
|
||||
const save = get(formData, 'value.imagesSaveOrCancel') === 0;
|
||||
return save ? this._saveImages(formData.value, cb) : this._backToMain(cb);
|
||||
},
|
||||
backToMain: (formData, extraArgs, cb) => {
|
||||
return this._backToMain(cb);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
initSequence() {
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return this.beforeArt(callback);
|
||||
},
|
||||
callback => {
|
||||
return this._displayMainPage(false, callback);
|
||||
},
|
||||
],
|
||||
() => {
|
||||
this.finishedLoading();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_backToMain(cb) {
|
||||
this.viewControllers.images.setFocus(false);
|
||||
return this._displayMainPage(true, cb);
|
||||
}
|
||||
|
||||
_manageImagesButton(cb) {
|
||||
this.viewControllers.main.setFocus(false);
|
||||
return this._displayImagesPage(true, cb);
|
||||
}
|
||||
|
||||
_save(values, cb) {
|
||||
const reqFields = [
|
||||
'enabled',
|
||||
'manuallyApproveFollowers',
|
||||
'hideSocialGraph',
|
||||
'showRealName',
|
||||
];
|
||||
if (
|
||||
!reqFields.every(p => {
|
||||
return true === !![values[p]];
|
||||
})
|
||||
) {
|
||||
return cb(Errors.BadFormData('One or more missing form values'));
|
||||
}
|
||||
|
||||
const apSettings = ActivityPubSettings.fromUser(this.client.user);
|
||||
apSettings.enabled = values.enabled;
|
||||
apSettings.manuallyApproveFollowers = values.manuallyApproveFollowers;
|
||||
apSettings.hideSocialGraph = values.hideSocialGraph;
|
||||
apSettings.showRealName = values.showRealName;
|
||||
|
||||
apSettings.persistToUserProperties(this.client.user, err => {
|
||||
if (err) {
|
||||
const user = this.client.user;
|
||||
this.client.log.warn(
|
||||
{ error: err.message, user: user.username },
|
||||
`Failed saving ActivityPub settings for user "${user.username}"`
|
||||
);
|
||||
}
|
||||
|
||||
return this.prevMenu(cb);
|
||||
});
|
||||
}
|
||||
|
||||
_saveImages(values, cb) {
|
||||
const apSettings = ActivityPubSettings.fromUser(this.client.user);
|
||||
apSettings.image = values.imageUrl.trim();
|
||||
apSettings.icon = values.iconUrl.trim();
|
||||
|
||||
apSettings.persistToUserProperties(this.client.user, err => {
|
||||
if (err) {
|
||||
if (err) {
|
||||
const user = this.client.user;
|
||||
this.client.log.warn(
|
||||
{ error: err.message, user: user.username },
|
||||
`Failed saving ActivityPub settings for user "${user.username}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this._backToMain(cb);
|
||||
});
|
||||
}
|
||||
|
||||
_displayMainPage(clearScreen, cb) {
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return this.displayArtAndPrepViewController(
|
||||
'main',
|
||||
FormIds.main,
|
||||
{ clearScreen },
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
return this.validateMCIByViewIds(
|
||||
'main',
|
||||
Object.values(MciViewIds.main).filter(
|
||||
i => i !== MciViewIds.main.customRangeStart
|
||||
),
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
const v = id => this.getView('main', id);
|
||||
|
||||
const enabledToggleView = v(MciViewIds.main.enabledToggle);
|
||||
const manuallyApproveFollowersToggleView = v(
|
||||
MciViewIds.main.manuallyApproveFollowersToggle
|
||||
);
|
||||
const hideSocialGraphToggleView = v(
|
||||
MciViewIds.main.hideSocialGraphToggle
|
||||
);
|
||||
const showRealNameToggleView = v(MciViewIds.main.showRealNameToggle);
|
||||
const imageView = v(MciViewIds.main.imageUrl);
|
||||
const iconView = v(MciViewIds.main.iconUrl);
|
||||
|
||||
const apSettings = ActivityPubSettings.fromUser(this.client.user);
|
||||
enabledToggleView.setFromBoolean(apSettings.enabled);
|
||||
manuallyApproveFollowersToggleView.setFromBoolean(
|
||||
apSettings.manuallyApproveFollowers
|
||||
);
|
||||
hideSocialGraphToggleView.setFromBoolean(apSettings.hideSocialGraph);
|
||||
showRealNameToggleView.setFromBoolean(apSettings.showRealName);
|
||||
imageView.setText(
|
||||
truncate(apSettings.image, { length: imageView.getWidth() })
|
||||
);
|
||||
iconView.setText(
|
||||
truncate(apSettings.icon, { length: iconView.getWidth() })
|
||||
);
|
||||
|
||||
this._toggleEnabledViewGroup();
|
||||
this._updateCustomViews();
|
||||
|
||||
enabledToggleView.on('index update', () => {
|
||||
this._toggleEnabledViewGroup();
|
||||
this._updateCustomViews();
|
||||
});
|
||||
|
||||
return callback(null);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_displayImagesPage(clearScreen, cb) {
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return this.displayArtAndPrepViewController(
|
||||
'images',
|
||||
FormIds.images,
|
||||
{ clearScreen },
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
return this.validateMCIByViewIds(
|
||||
'images',
|
||||
Object.values(MciViewIds.images),
|
||||
callback
|
||||
);
|
||||
},
|
||||
callback => {
|
||||
const v = id => this.getView('images', id);
|
||||
|
||||
const imageView = v(MciViewIds.images.imageUrl);
|
||||
const iconView = v(MciViewIds.images.iconUrl);
|
||||
|
||||
const apSettings = ActivityPubSettings.fromUser(this.client.user);
|
||||
imageView.setText(apSettings.image);
|
||||
iconView.setText(apSettings.icon);
|
||||
|
||||
imageView.setFocus(true);
|
||||
|
||||
return callback(null);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_toggleEnabledViewGroup() {
|
||||
const enabledToggleView = this.getView('main', MciViewIds.main.enabledToggle);
|
||||
EnabledViewGroup.forEach(id => {
|
||||
const v = this.getView('main', id);
|
||||
v.acceptsFocus = enabledToggleView.isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
_updateCustomViews() {
|
||||
const enabledToggleView = this.getView('main', MciViewIds.main.enabledToggle);
|
||||
const enabled = enabledToggleView.isTrue();
|
||||
const formatObj = {
|
||||
enabled,
|
||||
status: enabled ? 'enabled' : 'disabled',
|
||||
subject: enabled ? userNameToSubject(this.client.user.username) : 'N/A',
|
||||
};
|
||||
|
||||
this.updateCustomViewTextsWithFilter(
|
||||
'main',
|
||||
MciViewIds.main.customRangeStart,
|
||||
formatObj
|
||||
);
|
||||
}
|
||||
|
||||
_webServer() {
|
||||
if (undefined === this.webServer) {
|
||||
this.webServer = getServer('codes.l33t.enigma.web.server');
|
||||
}
|
||||
return this.webServer ? this.webServer.instance : null;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,305 @@
|
|||
const User = require('../user');
|
||||
const { Errors, ErrorReasons } = require('../enig_error');
|
||||
const UserProps = require('../user_property');
|
||||
const ActivityPubSettings = require('./settings');
|
||||
const { stripAnsiControlCodes } = require('../string_util');
|
||||
const { WellKnownRecipientFields } = require('./const');
|
||||
const Log = require('../logger').log;
|
||||
const { getWebDomain } = require('../web_util');
|
||||
const Endpoints = require('./endpoint');
|
||||
|
||||
// deps
|
||||
const _ = require('lodash');
|
||||
const mimeTypes = require('mime-types');
|
||||
const waterfall = require('async/waterfall');
|
||||
const fs = require('graceful-fs');
|
||||
const paths = require('path');
|
||||
const moment = require('moment');
|
||||
const { encode, decode } = require('html-entities');
|
||||
const { isString, get } = require('lodash');
|
||||
const { stripHtml } = require('string-strip-html');
|
||||
|
||||
exports.getActorId = o => o.actor?.id || o.actor;
|
||||
exports.parseTimestampOrNow = parseTimestampOrNow;
|
||||
exports.isValidLink = isValidLink;
|
||||
exports.userFromActorId = userFromActorId;
|
||||
exports.getUserProfileTemplatedBody = getUserProfileTemplatedBody;
|
||||
exports.messageBodyToHtml = messageBodyToHtml;
|
||||
exports.messageToHtml = messageToHtml;
|
||||
exports.htmlToMessageBody = htmlToMessageBody;
|
||||
exports.userNameFromSubject = userNameFromSubject;
|
||||
exports.userNameToSubject = userNameToSubject;
|
||||
exports.extractMessageMetadata = extractMessageMetadata;
|
||||
exports.recipientIdsFromObject = recipientIdsFromObject;
|
||||
exports.prepareLocalUserAsActor = prepareLocalUserAsActor;
|
||||
|
||||
// :TODO: more info in default
|
||||
// this profile template is the *default* for both WebFinger
|
||||
// profiles and 'self' requests without the
|
||||
// Accept: application/activity+json headers present
|
||||
exports.DefaultProfileTemplate = `
|
||||
User information for: %PREFERRED_USERNAME%
|
||||
|
||||
Name: %NAME%
|
||||
Login Count: %LOGIN_COUNT%
|
||||
Affiliations: %AFFILIATIONS%
|
||||
Achievement Points: %ACHIEVEMENT_POINTS%
|
||||
`;
|
||||
|
||||
function parseTimestampOrNow(s) {
|
||||
try {
|
||||
return moment(s);
|
||||
} catch (e) {
|
||||
Log.warn({ error: e.message }, `Failed parsing timestamp "${s}"`);
|
||||
return moment();
|
||||
}
|
||||
}
|
||||
|
||||
function isValidLink(l) {
|
||||
return /^https?:\/\/.+$/.test(l);
|
||||
}
|
||||
|
||||
function userFromActorId(actorId, cb) {
|
||||
User.getUserIdsWithProperty(UserProps.ActivityPubActorId, actorId, (err, userId) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
// must only be 0 or 1
|
||||
if (!Array.isArray(userId) || userId.length !== 1) {
|
||||
return cb(
|
||||
Errors.DoesNotExist(
|
||||
`No user with property '${UserProps.ActivityPubActorId}' of ${actorId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
userId = userId[0];
|
||||
User.getUser(userId, (err, user) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
const accountStatus = user.getPropertyAsNumber(UserProps.AccountStatus);
|
||||
if (
|
||||
User.AccountStatus.disabled == accountStatus ||
|
||||
User.AccountStatus.inactive == accountStatus
|
||||
) {
|
||||
return cb(Errors.AccessDenied('Account disabled', ErrorReasons.Disabled));
|
||||
}
|
||||
|
||||
const activityPubSettings = ActivityPubSettings.fromUser(user);
|
||||
if (!activityPubSettings.enabled) {
|
||||
return cb(Errors.AccessDenied('ActivityPub is not enabled for user'));
|
||||
}
|
||||
|
||||
return cb(null, user);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getUserProfileTemplatedBody(
|
||||
templateFile,
|
||||
user,
|
||||
userAsActor,
|
||||
defaultTemplate,
|
||||
defaultContentType,
|
||||
cb
|
||||
) {
|
||||
const Log = require('../logger').log;
|
||||
const Config = require('../config').get;
|
||||
|
||||
waterfall(
|
||||
[
|
||||
callback => {
|
||||
return fs.readFile(templateFile || '', 'utf8', (err, template) => {
|
||||
return callback(null, template);
|
||||
});
|
||||
},
|
||||
(template, callback) => {
|
||||
if (!template) {
|
||||
if (templateFile) {
|
||||
Log.warn(`Failed to load profile template "${templateFile}"`);
|
||||
}
|
||||
return callback(null, defaultTemplate, defaultContentType);
|
||||
}
|
||||
|
||||
const contentType = mimeTypes.contentType(paths.basename(templateFile));
|
||||
return callback(null, template, contentType);
|
||||
},
|
||||
(template, contentType, callback) => {
|
||||
const val = v => {
|
||||
if (isString(v)) {
|
||||
return v ? encode(v) : '';
|
||||
} else {
|
||||
if (isNaN(v)) {
|
||||
return '';
|
||||
}
|
||||
return v ? v : 0;
|
||||
}
|
||||
};
|
||||
|
||||
let birthDate = val(user.getProperty(UserProps.Birthdate));
|
||||
if (moment.isDate(birthDate)) {
|
||||
birthDate = moment(birthDate);
|
||||
}
|
||||
|
||||
const varMap = {
|
||||
ACTOR_OBJ: JSON.stringify(userAsActor),
|
||||
SUBJECT: userNameToSubject(user.username),
|
||||
INBOX: userAsActor.inbox,
|
||||
SHARED_INBOX: userAsActor.endpoints.sharedInbox,
|
||||
OUTBOX: userAsActor.outbox,
|
||||
FOLLOWERS: userAsActor.followers,
|
||||
FOLLOWING: userAsActor.following,
|
||||
USER_ICON: get(userAsActor, 'icon.url', ''),
|
||||
USER_IMAGE: get(userAsActor, 'image.url', ''),
|
||||
PREFERRED_USERNAME: userAsActor.preferredUsername,
|
||||
NAME: userAsActor.name,
|
||||
SEX: user.getProperty(UserProps.Sex),
|
||||
BIRTHDATE: birthDate,
|
||||
AGE: user.getAge(),
|
||||
LOCATION: user.getProperty(UserProps.Location),
|
||||
AFFILIATIONS: user.getProperty(UserProps.Affiliations),
|
||||
EMAIL: user.getProperty(UserProps.EmailAddress),
|
||||
WEB_ADDRESS: user.getProperty(UserProps.WebAddress),
|
||||
ACCOUNT_CREATED: moment(user.getProperty(UserProps.AccountCreated)),
|
||||
LAST_LOGIN: moment(user.getProperty(UserProps.LastLoginTs)),
|
||||
LOGIN_COUNT: user.getPropertyAsNumber(UserProps.LoginCount),
|
||||
ACHIEVEMENT_COUNT: user.getPropertyAsNumber(
|
||||
UserProps.AchievementTotalCount
|
||||
),
|
||||
ACHIEVEMENT_POINTS: user.getPropertyAsNumber(
|
||||
UserProps.AchievementTotalPoints
|
||||
),
|
||||
BOARDNAME: Config().general.boardName,
|
||||
};
|
||||
|
||||
let body = template;
|
||||
_.each(varMap, (v, varName) => {
|
||||
body = body.replace(new RegExp(`%${varName}%`, 'g'), val(v));
|
||||
});
|
||||
|
||||
return callback(null, body, contentType);
|
||||
},
|
||||
],
|
||||
(err, data, contentType) => {
|
||||
return cb(err, data, contentType);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function messageBodyToHtml(body) {
|
||||
body = encode(stripAnsiControlCodes(body), { mode: 'nonAsciiPrintable' }).replace(
|
||||
/\r?\n/g,
|
||||
'<br>'
|
||||
);
|
||||
|
||||
return `<p>${body}</p>`;
|
||||
}
|
||||
|
||||
//
|
||||
// Apply very basic HTML to a message following
|
||||
// Mastodon's supported tags of 'p', 'br', 'a', and 'span':
|
||||
// - https://docs.joinmastodon.org/spec/activitypub/#sanitization
|
||||
// - https://blog.joinmastodon.org/2018/06/how-to-implement-a-basic-activitypub-server/
|
||||
//
|
||||
// Microformats:
|
||||
// - https://microformats.org/wiki/
|
||||
// - https://indieweb.org/note
|
||||
// - https://docs.joinmastodon.org/spec/microformats/
|
||||
//
|
||||
function messageToHtml(message) {
|
||||
const msg = encode(stripAnsiControlCodes(message.message.trim()), {
|
||||
mode: 'nonAsciiPrintable',
|
||||
}).replace(/\r?\n/g, '<br>');
|
||||
|
||||
// :TODO: figure out any microformats we should use here...
|
||||
|
||||
return `<p>${msg}</p>`;
|
||||
}
|
||||
|
||||
function htmlToMessageBody(html) {
|
||||
const res = stripHtml(decode(html));
|
||||
return res.result;
|
||||
}
|
||||
|
||||
function userNameFromSubject(subject) {
|
||||
return subject.replace(/^acct:(.+)$/, '$1');
|
||||
}
|
||||
|
||||
function userNameToSubject(userName) {
|
||||
return `@${userName}@${getWebDomain()}`;
|
||||
}
|
||||
|
||||
function extractMessageMetadata(body) {
|
||||
const metadata = { mentions: new Set(), hashTags: new Set() };
|
||||
|
||||
const re = /(@\w+)|(#[A-Za-z0-9_]+)/g;
|
||||
const matches = body.matchAll(re);
|
||||
for (const m of matches) {
|
||||
if (m[1]) {
|
||||
metadata.mentions.add(m[1]);
|
||||
} else if (m[2]) {
|
||||
metadata.hashTags.add(m[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function recipientIdsFromObject(obj) {
|
||||
const ids = [];
|
||||
|
||||
WellKnownRecipientFields.forEach(field => {
|
||||
let v = obj[field];
|
||||
if (v) {
|
||||
if (!Array.isArray(v)) {
|
||||
v = [v];
|
||||
}
|
||||
ids.push(...v);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
function prepareLocalUserAsActor(user, options = { force: false }, cb) {
|
||||
const hasProps =
|
||||
user.getProperty(UserProps.ActivityPubActorId) &&
|
||||
user.getProperty(UserProps.PrivateActivityPubSigningKey) &&
|
||||
user.getProperty(UserProps.PublicActivityPubSigningKey);
|
||||
|
||||
if (hasProps && !options.force) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
const actorId = Endpoints.actorId(user);
|
||||
user.setProperty(UserProps.ActivityPubActorId, actorId);
|
||||
|
||||
user.updateActivityPubKeyPairProperties(err => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
user.generateNewRandomAvatar((err, outPath) => {
|
||||
if (err) {
|
||||
return err;
|
||||
}
|
||||
|
||||
// :TODO: fetch over +op default overrides here, e.g. 'enabled'
|
||||
const apSettings = ActivityPubSettings.fromUser(user);
|
||||
|
||||
const filename = paths.basename(outPath);
|
||||
const avatarUrl = Endpoints.avatar(user, filename);
|
||||
|
||||
apSettings.image = avatarUrl;
|
||||
apSettings.icon = avatarUrl;
|
||||
|
||||
user.setProperty(UserProps.AvatarImageUrl, avatarUrl);
|
||||
user.setProperty(UserProps.ActivityPubSettings, JSON.stringify(apSettings));
|
||||
|
||||
return cb(null);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -40,7 +40,7 @@ function ANSIEscapeParser(options) {
|
|||
this.breakWidth = this.termWidth;
|
||||
// toNumber takes care of null, undefined etc as well.
|
||||
let artWidth = _.toNumber(options.artWidth);
|
||||
if(!(_.isNaN(artWidth)) && artWidth > 0 && artWidth < this.breakWidth) {
|
||||
if (!_.isNaN(artWidth) && artWidth > 0 && artWidth < this.breakWidth) {
|
||||
this.breakWidth = options.artWidth;
|
||||
}
|
||||
this.trailingLF = miscUtil.valueWithDefault(options.trailingLF, 'default');
|
||||
|
@ -88,8 +88,7 @@ function ANSIEscapeParser(options) {
|
|||
}
|
||||
self.emit('scroll', self.row - self.termHeight);
|
||||
self.row = self.termHeight;
|
||||
}
|
||||
else if(self.row < 1) {
|
||||
} else if (self.row < 1) {
|
||||
if (this.savedPosition) {
|
||||
this.savedPosition.row -= self.row - 1;
|
||||
}
|
||||
|
@ -287,7 +286,7 @@ function ANSIEscapeParser(options) {
|
|||
args = match[1].split(';').map(v => parseInt(v, 10)); // convert to array of ints
|
||||
|
||||
// Handle the case where there is no bracket
|
||||
if(!(_.isNil(match[3]))) {
|
||||
if (!_.isNil(match[3])) {
|
||||
opCode = match[3];
|
||||
args = [];
|
||||
// no bracket
|
||||
|
@ -322,8 +321,7 @@ function ANSIEscapeParser(options) {
|
|||
escape('T', args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
escape(opCode, args);
|
||||
}
|
||||
|
||||
|
@ -399,8 +397,7 @@ function ANSIEscapeParser(options) {
|
|||
if (this.row + arg > this.termHeight) {
|
||||
this.emit('scroll', arg - (this.termHeight - this.row));
|
||||
self.moveCursor(0, this.termHeight);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
self.moveCursor(0, arg);
|
||||
}
|
||||
break;
|
||||
|
@ -411,8 +408,7 @@ function ANSIEscapeParser(options) {
|
|||
if (this.row - arg < 1) {
|
||||
this.emit('scroll', -(arg - this.row));
|
||||
self.moveCursor(0, 1 - this.row);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
self.moveCursor(0, -arg);
|
||||
}
|
||||
break;
|
||||
|
@ -434,16 +430,13 @@ function ANSIEscapeParser(options) {
|
|||
self.positionUpdated();
|
||||
break;
|
||||
|
||||
|
||||
// erase display/screen
|
||||
case 'J':
|
||||
if (isNaN(args[0]) || 0 === args[0]) {
|
||||
self.emit('erase rows', self.row, self.termHeight);
|
||||
}
|
||||
else if (1 === args[0]) {
|
||||
} else if (1 === args[0]) {
|
||||
self.emit('erase rows', 1, self.row);
|
||||
}
|
||||
else if (2 === args[0]) {
|
||||
} else if (2 === args[0]) {
|
||||
self.clearScreen();
|
||||
}
|
||||
break;
|
||||
|
@ -452,11 +445,9 @@ function ANSIEscapeParser(options) {
|
|||
case 'K':
|
||||
if (isNaN(args[0]) || 0 === args[0]) {
|
||||
self.emit('erase columns', self.row, self.column, self.termWidth);
|
||||
}
|
||||
else if (1 === args[0]) {
|
||||
} else if (1 === args[0]) {
|
||||
self.emit('erase columns', self.row, 1, self.column);
|
||||
}
|
||||
else if (2 === args[0]) {
|
||||
} else if (2 === args[0]) {
|
||||
self.emit('erase columns', self.row, 1, self.termWidth);
|
||||
}
|
||||
break;
|
||||
|
@ -581,7 +572,6 @@ function ANSIEscapeParser(options) {
|
|||
arg = isNaN(args[0]) ? 1 : args[0];
|
||||
self.emit('insert columns', self.row, self.column, arg);
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -216,7 +216,7 @@ module.exports = class ArchiveUtil {
|
|||
|
||||
proc.onExit(exitEvent => {
|
||||
return cb(
|
||||
exitCode
|
||||
exitEvent.exitCode
|
||||
? Errors.ExternalProcess(
|
||||
`${action} failed with exit code: ${exitEvent.exitCode}`
|
||||
)
|
||||
|
@ -361,7 +361,9 @@ module.exports = class ArchiveUtil {
|
|||
proc.onExit(exitEvent => {
|
||||
if (exitEvent.exitCode) {
|
||||
return cb(
|
||||
Errors.ExternalProcess(`List failed with exit code: ${exitEvent.exitCode}`)
|
||||
Errors.ExternalProcess(
|
||||
`List failed with exit code: ${exitEvent.exitCode}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
11
core/art.js
11
core/art.js
|
@ -49,7 +49,7 @@ function getFontNameFromSAUCE(sauce) {
|
|||
function getWidthFromSAUCE(sauce) {
|
||||
if (sauce && sauce.Character) {
|
||||
let sauceWidth = _.toNumber(sauce.Character.characterWidth);
|
||||
if(!(_.isNaN(sauceWidth)) && sauceWidth > 0) {
|
||||
if (!_.isNaN(sauceWidth) && sauceWidth > 0) {
|
||||
return sauceWidth;
|
||||
}
|
||||
}
|
||||
|
@ -356,14 +356,14 @@ function display(client, art, options, cb) {
|
|||
});
|
||||
});
|
||||
|
||||
ansiParser.on('scroll', (scrollY) => {
|
||||
_.forEach(mciMap, (mciInfo) => {
|
||||
ansiParser.on('scroll', scrollY => {
|
||||
_.forEach(mciMap, mciInfo => {
|
||||
mciInfo.position[0] -= scrollY;
|
||||
});
|
||||
});
|
||||
|
||||
ansiParser.on('insert line', (row, numLines) => {
|
||||
_.forEach(mciMap, (mciInfo) => {
|
||||
_.forEach(mciMap, mciInfo => {
|
||||
if (mciInfo.position[0] >= row) {
|
||||
mciInfo.position[0] += numLines;
|
||||
}
|
||||
|
@ -377,8 +377,7 @@ function display(client, art, options, cb) {
|
|||
// unlike scrolling, the rows are actually gone,
|
||||
// so we need to delete any MCI's that are in them
|
||||
delete mciMap[mapKey];
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
mciInfo.position[0] -= numLines;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ const ALL_ASSETS = [
|
|||
];
|
||||
|
||||
const ASSET_RE = new RegExp(
|
||||
'^@(' + ALL_ASSETS.join('|') + ')' + /:(?:([^:]+):)?([A-Za-z0-9_\-.]+)$/.source
|
||||
'^@(' + ALL_ASSETS.join('|') + ')' + /:(?:([^:]+):)?([A-Za-z0-9_\-./]+)$/.source
|
||||
);
|
||||
|
||||
function parseAsset(s) {
|
||||
|
|
|
@ -80,13 +80,13 @@ exports.getModule = class BBSListModule extends MenuModule {
|
|||
const errMsgView = self.viewControllers.add.getView(MciViewIds.add.Error);
|
||||
if (errMsgView) {
|
||||
if (err) {
|
||||
errMsgView.setText(err.message);
|
||||
errMsgView.setText(err.friendlyText);
|
||||
} else {
|
||||
errMsgView.clearText();
|
||||
}
|
||||
}
|
||||
|
||||
return cb(null);
|
||||
return cb(err, null);
|
||||
},
|
||||
|
||||
//
|
||||
|
|
|
@ -5,6 +5,9 @@ const TextView = require('./text_view.js').TextView;
|
|||
const miscUtil = require('./misc_util.js');
|
||||
const util = require('util');
|
||||
|
||||
// deps
|
||||
const { isString } = require('lodash');
|
||||
|
||||
exports.ButtonView = ButtonView;
|
||||
|
||||
function ButtonView(options) {
|
||||
|
@ -33,3 +36,18 @@ ButtonView.prototype.onKeyPress = function (ch, key) {
|
|||
ButtonView.prototype.getData = function () {
|
||||
return this.submitData || null;
|
||||
};
|
||||
|
||||
ButtonView.prototype.setPropertyValue = function (propName, value) {
|
||||
switch (propName) {
|
||||
case 'itemFormat':
|
||||
case 'focusItemFormat':
|
||||
if (isString(value)) {
|
||||
this[propName] = value;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
ButtonView.super_.prototype.setPropertyValue.call(this, propName, value);
|
||||
};
|
||||
|
|
|
@ -100,6 +100,7 @@ module.exports = () => {
|
|||
'server',
|
||||
'client',
|
||||
'notme',
|
||||
'public',
|
||||
],
|
||||
|
||||
preAuthIdleLogoutSeconds: 60 * 3, // 3m
|
||||
|
@ -130,6 +131,20 @@ module.exports = () => {
|
|||
),
|
||||
},
|
||||
},
|
||||
|
||||
// path to avatar generation parts
|
||||
avatars: {
|
||||
storagePath: paths.join(__dirname, '../userdata/avatars/'),
|
||||
spritesPath: paths.join(__dirname, '../misc/avatar-sprites/'),
|
||||
},
|
||||
|
||||
// See also ./core/activitypub/settings.js
|
||||
activityPub: {
|
||||
enabled: false, // ActivityPub enabled for this user?
|
||||
manuallyApproveFollowers: false,
|
||||
hideSocialGraph: false,
|
||||
showRealName: true,
|
||||
},
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
@ -160,6 +175,7 @@ module.exports = () => {
|
|||
mods: paths.join(__dirname, './../mods/'),
|
||||
loginServers: paths.join(__dirname, './servers/login/'),
|
||||
contentServers: paths.join(__dirname, './servers/content/'),
|
||||
webHandlers: paths.join(__dirname, './servers/content/web_handlers'),
|
||||
chatServers: paths.join(__dirname, './servers/chat/'),
|
||||
|
||||
scannerTossers: paths.join(__dirname, './scanner_tossers/'),
|
||||
|
@ -279,6 +295,34 @@ module.exports = () => {
|
|||
|
||||
staticRoot: paths.join(__dirname, './../www'),
|
||||
|
||||
// Logging block works the same way the system logger does
|
||||
logging: {
|
||||
rotatingFile: {
|
||||
level: 'info',
|
||||
type: 'rotating-file',
|
||||
fileName: 'enigma-bbs.web.log',
|
||||
period: '1d',
|
||||
count: 3,
|
||||
},
|
||||
},
|
||||
|
||||
handlers: {
|
||||
systemGeneral: {
|
||||
enabled: true,
|
||||
},
|
||||
nodeInfo2: {
|
||||
enabled: true,
|
||||
},
|
||||
webFinger: {
|
||||
enabled: false,
|
||||
profileTemplate: './wf/profile.template.html',
|
||||
},
|
||||
activityPub: {
|
||||
enabled: false,
|
||||
selfTemplate: './wf/profile.template.html',
|
||||
},
|
||||
},
|
||||
|
||||
resetPassword: {
|
||||
//
|
||||
// The following templates have these variables available to them:
|
||||
|
@ -370,6 +414,18 @@ module.exports = () => {
|
|||
},
|
||||
},
|
||||
|
||||
// General ActivityPub integration configuration
|
||||
activityPub: {
|
||||
// by default, don't include auto-signatures in AP outgoing
|
||||
autoSignatures: false,
|
||||
|
||||
// Mimics Mastodon max 500 characters for *outgoing* Notes
|
||||
// (messages destined for ActivityPub); This is a soft limit;
|
||||
// Implementations including Mastodon should still display
|
||||
// longer messages, but this keeps us as a "good citizen"
|
||||
maxMessageLength: 500,
|
||||
},
|
||||
|
||||
infoExtractUtils: {
|
||||
Exiftool2Desc: {
|
||||
cmd: `${__dirname}/../util/exiftool2desc.js`, // ensure chmod +x
|
||||
|
@ -851,6 +907,27 @@ module.exports = () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
|
||||
activitypub_internal: {
|
||||
name: 'ActivityPub',
|
||||
desc: 'Public ActivityPub messages',
|
||||
|
||||
acs: {
|
||||
read: 'GM[users]SE[activitypub]AE1',
|
||||
},
|
||||
|
||||
areas: {
|
||||
activitypub_shared: {
|
||||
name: 'ActivityPub Public',
|
||||
desc: 'Public inbox for ActivityPub',
|
||||
alwaysExportExternal: true,
|
||||
subjectOptional: true,
|
||||
addressFlavor: 'activitypub',
|
||||
maxAgeDays: 365 * 2,
|
||||
maxMessages: 100000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
scannerTossers: {
|
||||
|
@ -1012,6 +1089,12 @@ module.exports = () => {
|
|||
],
|
||||
},
|
||||
|
||||
// Removes old Actor records
|
||||
activityPubActorCacheMaintenance: {
|
||||
schedule: 'every 24 hours',
|
||||
action: '@method:/core/activitypub/actor.js:actorCacheMaintenanceTask',
|
||||
},
|
||||
|
||||
//
|
||||
// Enable the following entry in your config.hjson to periodically create/update
|
||||
// DESCRIPT.ION files for your file base
|
||||
|
|
|
@ -105,7 +105,7 @@ function ansiAttemptDetectUTF8(client, cb) {
|
|||
withCursorPositionReport(
|
||||
client,
|
||||
pos => {
|
||||
const [_, w] = pos;
|
||||
const [, w] = pos;
|
||||
const len = w - initialPosition[1];
|
||||
if (!isNaN(len) && len >= ASCIIPortion.length + 6) {
|
||||
// CP437 displays 3 chars each Unicode skull
|
||||
|
@ -154,7 +154,7 @@ const ansiQuerySyncTermFontSupport = (client, cb) => {
|
|||
withCursorPositionReport(
|
||||
client,
|
||||
pos => {
|
||||
const [_, w] = pos;
|
||||
const [, w] = pos;
|
||||
if (w === 1) {
|
||||
// cursor didn't move
|
||||
client.log.info(`SyncTERM font support enabled on node ${client.node}`);
|
||||
|
@ -234,7 +234,7 @@ function displayBanner(term) {
|
|||
// note: intentional formatting:
|
||||
term.pipeWrite(`
|
||||
|06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN
|
||||
|06Copyright (c) 2014-2022 Bryan Ashby |14- |12http://l33t.codes/
|
||||
|06Copyright (c) 2014-2023 Bryan Ashby |14- |12http://l33t.codes/
|
||||
|06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/
|
||||
|00`);
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ exports.loadDatabaseForMod = loadDatabaseForMod;
|
|||
exports.getISOTimestampString = getISOTimestampString;
|
||||
exports.sanitizeString = sanitizeString;
|
||||
exports.initializeDatabases = initializeDatabases;
|
||||
exports.scheduledEventOptimizeDatabases = scheduledEventOptimizeDatabases;
|
||||
|
||||
exports.dbs = dbs;
|
||||
|
||||
|
@ -109,7 +110,7 @@ function sanitizeString(s) {
|
|||
|
||||
function initializeDatabases(cb) {
|
||||
async.eachSeries(
|
||||
['system', 'user', 'message', 'file'],
|
||||
['system', 'user', 'message', 'file', 'activitypub'],
|
||||
(dbName, next) => {
|
||||
dbs[dbName] = sqlite3Trans.wrap(
|
||||
new sqlite3.Database(getDatabasePath(dbName), err => {
|
||||
|
@ -242,7 +243,6 @@ const DB_INIT_TABLE = {
|
|||
|
||||
return cb(null);
|
||||
},
|
||||
|
||||
message: cb => {
|
||||
enableForeignKeys(dbs.message);
|
||||
|
||||
|
@ -469,6 +469,62 @@ const DB_INIT_TABLE = {
|
|||
);`
|
||||
);
|
||||
|
||||
return cb(null);
|
||||
},
|
||||
activitypub: cb => {
|
||||
enableForeignKeys(dbs.activitypub);
|
||||
|
||||
// ActivityPub Collections of various types such as followers, following, likes, ...
|
||||
dbs.activitypub.run(
|
||||
`CREATE TABLE IF NOT EXISTS collection (
|
||||
collection_id VARCHAR NOT NULL, -- ie: http://somewhere.com/_enig/ap/users/NuSkooler/followers
|
||||
name VARCHAR NOT NULL, -- examples: followers, follows, ...
|
||||
timestamp DATETIME NOT NULL, -- Timestamp in which this entry was created
|
||||
owner_actor_id VARCHAR NOT NULL, -- Local, owning Actor ID, or the #Public magic collection ID
|
||||
object_id VARCHAR NOT NULL, -- Object ID from obj_json.id
|
||||
object_json VARCHAR NOT NULL, -- Object varies by collection (obj_json.type)
|
||||
is_private INTEGER NOT NULL, -- Is this object private to |owner_actor_id|?
|
||||
|
||||
UNIQUE(name, collection_id, object_id)
|
||||
);`
|
||||
);
|
||||
|
||||
dbs.activitypub.run(
|
||||
`CREATE INDEX IF NOT EXISTS collection_entry_by_name_actor_id_index0
|
||||
ON collection (name, owner_actor_id);`
|
||||
);
|
||||
|
||||
dbs.activitypub.run(
|
||||
`CREATE INDEX IF NOT EXISTS collection_entry_by_name_collection_id_index0
|
||||
ON collection (name, collection_id);`
|
||||
);
|
||||
|
||||
// Collection meta contains 0:N additional metadata records for a object_id in a collection
|
||||
dbs.activitypub.run(
|
||||
`CREATE TABLE IF NOT EXISTS collection_object_meta (
|
||||
collection_id VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
object_id VARCHAR NOT NULL,
|
||||
meta_name VARCHAR NOT NULL,
|
||||
meta_value VARCHAR NOT NULL,
|
||||
|
||||
UNIQUE(collection_id, object_id, meta_name),
|
||||
FOREIGN KEY(name, collection_id, object_id) REFERENCES collection(name, collection_id, object_id) ON DELETE CASCADE
|
||||
);`
|
||||
);
|
||||
|
||||
return cb(null);
|
||||
},
|
||||
};
|
||||
|
||||
function scheduledEventOptimizeDatabases(args, cb) {
|
||||
async.forEachSeries(
|
||||
Object.keys(dbs),
|
||||
(db, nextDb) => {
|
||||
return db.run('PRAGMA OPTIMIZE', nextDb);
|
||||
},
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ exports.Errors = {
|
|||
UnexpectedState: (reason, reasonCode) =>
|
||||
new EnigError('Unexpected state', -32007, reason, reasonCode),
|
||||
MissingParam: (reason, reasonCode) =>
|
||||
new EnigError('Missing paramter(s)', -32008, reason, reasonCode),
|
||||
new EnigError('Missing parameter(s)', -32008, reason, reasonCode),
|
||||
MissingMci: (reason, reasonCode) =>
|
||||
new EnigError('Missing required MCI code(s)', -32009, reason, reasonCode),
|
||||
BadLogin: (reason, reasonCode) =>
|
||||
|
@ -51,6 +51,18 @@ exports.Errors = {
|
|||
new EnigError('User interrupted', -32011, reason, reasonCode),
|
||||
NothingToDo: (reason, reasonCode) =>
|
||||
new EnigError('Nothing to do', -32012, reason, reasonCode),
|
||||
HttpError: (reason, reasonCode) =>
|
||||
new EnigError('HTTP error', -32013, reason, reasonCode),
|
||||
Timeout: (reason, reasonCode) => new EnigError('Timeout', -32014, reason, reasonCode),
|
||||
MissingProperty: (reason, reasonCode) =>
|
||||
new EnigError('Missing property', -32014, reason, reasonCode),
|
||||
Expired: (reason, reasonCode) => new EnigError('Expired', -32015, reason, reasonCode),
|
||||
BadFormData: (reason, reasonCode) =>
|
||||
new EnigError('Bad or missing form data', -32016, reason, reasonCode),
|
||||
Duplicate: (reason, reasonCode) =>
|
||||
new EnigError('Duplicate', -32017, reason, reasonCode),
|
||||
ValidationFailed: (reason, reasonCode) =>
|
||||
new EnigError('Validation failed', -32018, reason, reasonCode),
|
||||
};
|
||||
|
||||
exports.ErrorReasons = {
|
||||
|
@ -66,4 +78,11 @@ exports.ErrorReasons = {
|
|||
Locked: 'LOCKED',
|
||||
NotAllowed: 'NOTALLOWED',
|
||||
Invalid2FA: 'INVALID2FA',
|
||||
|
||||
ValueTooShort: 'VALUE_TOO_SHORT',
|
||||
ValueTooLong: 'VALUE_TOO_LONG',
|
||||
ValueInvalid: 'VALUE_INVALID',
|
||||
|
||||
NotAvailable: 'NOT_AVAILABLE',
|
||||
DoesNotExist: 'EEXIST',
|
||||
};
|
||||
|
|
|
@ -170,7 +170,11 @@ class ScheduledEvent {
|
|||
proc.onExit(exitEvent => {
|
||||
if (exitEvent.exitCode) {
|
||||
Log.warn(
|
||||
{ eventName: this.name, action: this.action, exitCode: exitEvent.exitCode },
|
||||
{
|
||||
eventName: this.name,
|
||||
action: this.action,
|
||||
exitCode: exitEvent.exitCode,
|
||||
},
|
||||
'Bad exit code while performing scheduled event action'
|
||||
);
|
||||
}
|
||||
|
|
|
@ -146,18 +146,17 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule {
|
|||
const errorView = this.viewControllers.editor.getView(
|
||||
MciViewIds.editor.error
|
||||
);
|
||||
let newFocusId;
|
||||
|
||||
if (errorView) {
|
||||
if (err) {
|
||||
errorView.setText(err.message);
|
||||
errorView.setText(err.friendlyText);
|
||||
err.view.clearText(); // clear out the invalid data
|
||||
} else {
|
||||
errorView.clearText();
|
||||
}
|
||||
}
|
||||
|
||||
return cb(newFocusId);
|
||||
return cb(err, null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -344,7 +344,7 @@ exports.getModule = class FileAreaList extends MenuModule {
|
|||
);
|
||||
}
|
||||
|
||||
displayArtDataPrepCallback(name, artData, viewController) {
|
||||
displayArtDataPrepCallback(name, artData) {
|
||||
if (name === 'details') {
|
||||
try {
|
||||
this.detailsInfoArea = {
|
||||
|
|
|
@ -17,6 +17,7 @@ const webServerPackageName = require('./servers/content/web.js').moduleInfo.pack
|
|||
const Events = require('./events.js');
|
||||
const UserProps = require('./user_property.js');
|
||||
const SysProps = require('./system_menu_method.js');
|
||||
const { buildUrl } = require('./web_util');
|
||||
|
||||
// deps
|
||||
const hashids = require('hashids/cjs');
|
||||
|
@ -202,11 +203,11 @@ class FileAreaWebAccess {
|
|||
buildSingleFileTempDownloadLink(client, fileEntry, hashId) {
|
||||
hashId = hashId || this.getSingleFileHashId(client, fileEntry);
|
||||
|
||||
return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`);
|
||||
return buildUrl(`${Config().fileBase.web.path}${hashId}`);
|
||||
}
|
||||
|
||||
buildBatchArchiveTempDownloadLink(client, hashId) {
|
||||
return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`);
|
||||
return buildUrl(`${Config().fileBase.web.path}${hashId}`);
|
||||
}
|
||||
|
||||
getExistingTempDownloadServeItem(client, fileEntry, cb) {
|
||||
|
|
|
@ -12,7 +12,6 @@ const Config = require('./config.js').get;
|
|||
|
||||
// deps
|
||||
const async = require('async');
|
||||
const _ = require('lodash');
|
||||
const moment = require('moment');
|
||||
|
||||
exports.moduleInfo = {
|
||||
|
|
163
core/fse.js
163
core/fse.js
|
@ -16,23 +16,30 @@ const { MessageAreaConfTempSwitcher } = require('./mod_mixins.js');
|
|||
const { isAnsi, stripAnsiControlCodes, insert } = require('./string_util.js');
|
||||
const { stripMciColorCodes, controlCodesToAnsi } = require('./color_codes.js');
|
||||
const Config = require('./config.js').get;
|
||||
const { getAddressedToInfo } = require('./mail_util.js');
|
||||
const {
|
||||
getAddressedToInfo,
|
||||
messageInfoFromAddressedToInfo,
|
||||
setExternalAddressedToInfo,
|
||||
copyExternalAddressedToInfo,
|
||||
getReplyToMessagePrefix,
|
||||
} = require('./mail_util.js');
|
||||
const Events = require('./events.js');
|
||||
const UserProps = require('./user_property.js');
|
||||
const SysProps = require('./system_property.js');
|
||||
const FileArea = require('./file_base_area.js');
|
||||
const FileEntry = require('./file_entry.js');
|
||||
const DownloadQueue = require('./download_queue.js');
|
||||
const EngiAssert = require('./enigma_assert.js');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
const assert = require('assert');
|
||||
const _ = require('lodash');
|
||||
const moment = require('moment');
|
||||
const fse = require('fs-extra');
|
||||
const fs = require('graceful-fs');
|
||||
const paths = require('path');
|
||||
const sanatizeFilename = require('sanitize-filename');
|
||||
const sanitizeFilename = require('sanitize-filename');
|
||||
const { ErrorReasons } = require('./enig_error.js');
|
||||
|
||||
exports.moduleInfo = {
|
||||
name: 'Full Screen Editor (FSE)',
|
||||
|
@ -117,7 +124,7 @@ exports.FullScreenEditorModule =
|
|||
this.editorMode = config.editorMode;
|
||||
|
||||
if (config.messageAreaTag) {
|
||||
// :TODO: swtich to this.config.messageAreaTag so we can follow Object.assign pattern for config/extraArgs
|
||||
// :TODO: switch to this.config.messageAreaTag so we can follow Object.assign pattern for config/extraArgs
|
||||
this.messageAreaTag = config.messageAreaTag;
|
||||
}
|
||||
|
||||
|
@ -160,15 +167,36 @@ exports.FullScreenEditorModule =
|
|||
//
|
||||
// Validation stuff
|
||||
//
|
||||
viewValidationListener: function (err, cb) {
|
||||
var errMsgView = self.viewControllers.header.getView(
|
||||
viewValidationListener: (err, cb) => {
|
||||
if (
|
||||
err &&
|
||||
err.view.getId() === MciViewIds.header.subject &&
|
||||
err.reasonCode === ErrorReasons.ValueTooShort
|
||||
) {
|
||||
// Ignore validation errors if this is the subject field
|
||||
// and it's optional
|
||||
const areaInfo = getMessageAreaByTag(this.messageAreaTag);
|
||||
if (true === areaInfo.subjectOptional) {
|
||||
return cb(null, null);
|
||||
}
|
||||
|
||||
// private messages are a little different...
|
||||
const toView = this.getView('header', MciViewIds.header.to);
|
||||
const msgInfo = messageInfoFromAddressedToInfo(
|
||||
getAddressedToInfo(toView.getData())
|
||||
);
|
||||
if (true === msgInfo.subjectOptional) {
|
||||
return cb(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
const errMsgView = this.viewControllers.header.getView(
|
||||
MciViewIds.header.errorMsg
|
||||
);
|
||||
var newFocusViewId;
|
||||
if (errMsgView) {
|
||||
if (err) {
|
||||
errMsgView.clearText();
|
||||
errMsgView.setText(err.message);
|
||||
errMsgView.setText(err.friendlyText || err.message);
|
||||
|
||||
if (MciViewIds.header.subject === err.view.getId()) {
|
||||
// :TODO: for "area" mode, should probably just bail if this is emtpy (e.g. cancel)
|
||||
|
@ -177,7 +205,8 @@ exports.FullScreenEditorModule =
|
|||
errMsgView.clearText();
|
||||
}
|
||||
}
|
||||
cb(newFocusViewId);
|
||||
|
||||
return cb(err, null);
|
||||
},
|
||||
headerSubmit: function (formData, extraArgs, cb) {
|
||||
self.switchToBody();
|
||||
|
@ -235,7 +264,7 @@ exports.FullScreenEditorModule =
|
|||
if (self.newQuoteBlock) {
|
||||
self.newQuoteBlock = false;
|
||||
|
||||
// :TODO: If replying to ANSI, add a blank sepration line here
|
||||
// :TODO: If replying to ANSI, add a blank separation line here
|
||||
|
||||
quoteMsgView.addText(self.getQuoteByHeader());
|
||||
}
|
||||
|
@ -334,7 +363,7 @@ exports.FullScreenEditorModule =
|
|||
return {
|
||||
// :TODO: ensure we show real names for form/to if they are enforced in the area
|
||||
fromUserName: this.message.fromUserName,
|
||||
toUserName: this.message.toUserName,
|
||||
toUserName: this._viewModeToField(),
|
||||
// :TODO:
|
||||
//fromRealName
|
||||
//toRealName
|
||||
|
@ -428,12 +457,17 @@ exports.FullScreenEditorModule =
|
|||
//
|
||||
// Append auto-signature, if enabled for the area & the user has one
|
||||
//
|
||||
if (false != area.autoSignatures) {
|
||||
const msgInfo = messageInfoFromAddressedToInfo(
|
||||
getAddressedToInfo(headerValues.to)
|
||||
);
|
||||
if (false !== msgInfo.autoSignatures) {
|
||||
if (false !== area.autoSignatures) {
|
||||
const sig = this.client.user.getProperty(UserProps.AutoSignature);
|
||||
if (sig) {
|
||||
messageBody += `\r\n-- \r\n${sig}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finally, create the message
|
||||
msgOpts.message = messageBody;
|
||||
|
@ -459,7 +493,10 @@ exports.FullScreenEditorModule =
|
|||
this.message = message;
|
||||
|
||||
this.updateLastReadId(() => {
|
||||
if (this.isReady) {
|
||||
if (!this.isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initHeaderViewMode();
|
||||
this.initFooterViewMode();
|
||||
|
||||
|
@ -484,7 +521,7 @@ exports.FullScreenEditorModule =
|
|||
msg = insert(
|
||||
msg,
|
||||
tearLinePos,
|
||||
bodyMessageView.getSGRFor('text')
|
||||
bodyMessageView.getTextSgrPrefix()
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -560,7 +597,6 @@ exports.FullScreenEditorModule =
|
|||
bodyMessageView.setText(controlCodesToAnsi(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -579,7 +615,11 @@ exports.FullScreenEditorModule =
|
|||
function populateLocalUserInfo(callback) {
|
||||
self.message.setLocalFromUserId(self.client.user.userId);
|
||||
|
||||
if (!self.isPrivateMail()) {
|
||||
const areaInfo = getMessageAreaByTag(self.messageAreaTag);
|
||||
if (
|
||||
!self.isPrivateMail() &&
|
||||
true !== areaInfo.alwaysExportExternal
|
||||
) {
|
||||
return callback(null);
|
||||
}
|
||||
|
||||
|
@ -597,15 +637,9 @@ exports.FullScreenEditorModule =
|
|||
self.replyToMessage &&
|
||||
self.replyToMessage.isFromRemoteUser()
|
||||
) {
|
||||
self.message.setRemoteToUser(
|
||||
self.replyToMessage.meta.System[
|
||||
Message.SystemMetaNames.RemoteFromUser
|
||||
]
|
||||
);
|
||||
self.message.setExternalFlavor(
|
||||
self.replyToMessage.meta.System[
|
||||
Message.SystemMetaNames.ExternalFlavor
|
||||
]
|
||||
copyExternalAddressedToInfo(
|
||||
self.replyToMessage,
|
||||
self.message
|
||||
);
|
||||
return callback(null);
|
||||
}
|
||||
|
@ -613,28 +647,33 @@ exports.FullScreenEditorModule =
|
|||
//
|
||||
// Detect if the user is attempting to send to a remote mail type that we support
|
||||
//
|
||||
// :TODO: how to plug in support without tying to various types here? isSupportedExteranlType() or such
|
||||
const addressedToInfo = getAddressedToInfo(
|
||||
self.message.toUserName
|
||||
);
|
||||
if (
|
||||
addressedToInfo.name &&
|
||||
Message.AddressFlavor.FTN === addressedToInfo.flavor
|
||||
) {
|
||||
self.message.setRemoteToUser(addressedToInfo.remote);
|
||||
self.message.setExternalFlavor(addressedToInfo.flavor);
|
||||
self.message.toUserName = addressedToInfo.name;
|
||||
|
||||
if (setExternalAddressedToInfo(addressedToInfo, self.message)) {
|
||||
// setExternalAddressedToInfo() did what we need
|
||||
return callback(null);
|
||||
}
|
||||
|
||||
// we need to look it up
|
||||
// Local user -- we need to look it up
|
||||
User.getUserIdAndNameByLookup(
|
||||
self.message.toUserName,
|
||||
(err, toUserId) => {
|
||||
if (err) {
|
||||
if (self.message.isPrivate()) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (areaInfo.addressFlavor) {
|
||||
self.message.setExternalFlavor(
|
||||
areaInfo.addressFlavor
|
||||
);
|
||||
}
|
||||
|
||||
return callback(null);
|
||||
}
|
||||
|
||||
self.message.setLocalToUserId(toUserId);
|
||||
return callback(null);
|
||||
}
|
||||
|
@ -825,7 +864,7 @@ exports.FullScreenEditorModule =
|
|||
const self = this;
|
||||
var art = self.menuConfig.config.art;
|
||||
|
||||
assert(_.isObject(art));
|
||||
EngiAssert(_.isObject(art));
|
||||
|
||||
async.waterfall(
|
||||
[
|
||||
|
@ -1080,6 +1119,24 @@ exports.FullScreenEditorModule =
|
|||
this.setViewText('header', id, text);
|
||||
}
|
||||
|
||||
_viewModeToField() {
|
||||
// Imported messages may have no explicit 'to' on various public forums
|
||||
if (this.message.toUserName) {
|
||||
return this.message.toUserName;
|
||||
}
|
||||
|
||||
const toRemoteUser = _.get(this.message, 'meta.System.remote_to_user');
|
||||
if (toRemoteUser) {
|
||||
return toRemoteUser;
|
||||
}
|
||||
|
||||
if (this.message.isPublic()) {
|
||||
return '(Public)';
|
||||
}
|
||||
|
||||
this.menuConfig.config.remoteUserNotAvail || 'N/A';
|
||||
}
|
||||
|
||||
initHeaderViewMode() {
|
||||
// Only set header text for from view if it is on the form
|
||||
if (
|
||||
|
@ -1087,7 +1144,7 @@ exports.FullScreenEditorModule =
|
|||
) {
|
||||
this.setHeaderText(MciViewIds.header.from, this.message.fromUserName);
|
||||
}
|
||||
this.setHeaderText(MciViewIds.header.to, this.message.toUserName);
|
||||
this.setHeaderText(MciViewIds.header.to, this._viewModeToField());
|
||||
this.setHeaderText(MciViewIds.header.subject, this.message.subject);
|
||||
|
||||
this.setHeaderText(
|
||||
|
@ -1115,7 +1172,7 @@ exports.FullScreenEditorModule =
|
|||
}
|
||||
|
||||
initHeaderReplyEditMode() {
|
||||
assert(_.isObject(this.replyToMessage));
|
||||
EngiAssert(_.isObject(this.replyToMessage));
|
||||
|
||||
this.setHeaderText(MciViewIds.header.to, this.replyToMessage.fromUserName);
|
||||
|
||||
|
@ -1131,6 +1188,20 @@ exports.FullScreenEditorModule =
|
|||
this.setHeaderText(MciViewIds.header.subject, newSubj);
|
||||
}
|
||||
|
||||
initBodyReplyEditMode() {
|
||||
EngiAssert(_.isObject(this.replyToMessage));
|
||||
|
||||
const bodyMessageView = this.viewControllers.body.getView(
|
||||
MciViewIds.body.message
|
||||
);
|
||||
|
||||
const messagePrefix = getReplyToMessagePrefix(
|
||||
this.replyToMessage.fromUserName
|
||||
);
|
||||
|
||||
bodyMessageView.setText(messagePrefix);
|
||||
}
|
||||
|
||||
initFooterViewMode() {
|
||||
this.setViewText(
|
||||
'footerView',
|
||||
|
@ -1171,7 +1242,7 @@ exports.FullScreenEditorModule =
|
|||
|
||||
const outputFileName = paths.join(
|
||||
sysTempDownloadDir,
|
||||
sanatizeFilename(
|
||||
sanitizeFilename(
|
||||
`(${msgInfo.messageId}) ${
|
||||
msgInfo.subject
|
||||
}_(${this.message.modTimestamp.format('YYYY-MM-DD')}).txt`
|
||||
|
@ -1402,6 +1473,20 @@ exports.FullScreenEditorModule =
|
|||
}
|
||||
|
||||
switchToBody() {
|
||||
const to = this.getView('header', MciViewIds.header.to).getData();
|
||||
const msgInfo = messageInfoFromAddressedToInfo(getAddressedToInfo(to));
|
||||
const bodyView = this.getView('body', MciViewIds.body.message);
|
||||
|
||||
if (msgInfo.maxMessageLength > 0) {
|
||||
bodyView.maxLength = msgInfo.maxMessageLength;
|
||||
}
|
||||
|
||||
// first pass through, init body (we may need header values set)
|
||||
const bodyText = bodyView.getData();
|
||||
if (!bodyText && this.isReply()) {
|
||||
this.initBodyReplyEditMode();
|
||||
}
|
||||
|
||||
this.viewControllers.header.setFocus(false);
|
||||
this.viewControllers.body.switchFocus(1);
|
||||
|
||||
|
@ -1443,7 +1528,7 @@ exports.FullScreenEditorModule =
|
|||
const bodyMessageView = this.viewControllers.body.getView(
|
||||
MciViewIds.body.message
|
||||
);
|
||||
quoteLines += `${ansi.normal()}${bodyMessageView.getSGRFor('text')}`;
|
||||
quoteLines += `${ansi.normal()}${bodyMessageView.getTextSgrPrefix()}`;
|
||||
}
|
||||
msgView.addText(`${quoteLines}\n\n`);
|
||||
}
|
||||
|
|
|
@ -59,7 +59,7 @@ function FullMenuView(options) {
|
|||
}
|
||||
|
||||
for (let i = 0; i < this.dimens.height; i++) {
|
||||
const text = `${strUtil.pad(this.fillChar, width, this.fillChar, 'left')}`;
|
||||
const text = strUtil.pad('', width, this.fillChar);
|
||||
this.client.term.write(
|
||||
`${ansi.goto(
|
||||
this.position.row + i,
|
||||
|
@ -77,6 +77,7 @@ function FullMenuView(options) {
|
|||
this.autoAdjustHeightIfEnabled();
|
||||
|
||||
this.pages = []; // reset
|
||||
this.currentPage = 0; // reset currentPage when pages reset
|
||||
|
||||
// Calculate number of items visible per column
|
||||
this.itemsPerRow = Math.floor(this.dimens.height / (this.itemSpacing + 1));
|
||||
|
@ -240,14 +241,25 @@ function FullMenuView(options) {
|
|||
sgr = index === this.focusedItemIndex ? this.getFocusSGR() : this.getSGR();
|
||||
}
|
||||
|
||||
let renderLength = strUtil.renderStringLength(text);
|
||||
if (this.hasTextOverflow() && item.col + renderLength > this.dimens.width) {
|
||||
text =
|
||||
strUtil.renderSubstr(
|
||||
text,
|
||||
0,
|
||||
this.dimens.width - (item.col + this.textOverflow.length)
|
||||
) + this.textOverflow;
|
||||
const renderLength = strUtil.renderStringLength(text);
|
||||
|
||||
let relativeColumn = item.col - this.position.col;
|
||||
if (relativeColumn < 0) {
|
||||
relativeColumn = 0;
|
||||
this.client.log.warn(
|
||||
{ itemCol: item.col, positionColumn: this.position.col },
|
||||
'Invalid item column detected in full menu'
|
||||
);
|
||||
}
|
||||
|
||||
if (relativeColumn + renderLength > this.dimens.width) {
|
||||
if (this.hasTextOverflow()) {
|
||||
text = strUtil.renderTruncate(text, {
|
||||
length:
|
||||
this.dimens.width - (relativeColumn + this.textOverflow.length),
|
||||
omission: this.textOverflow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let padLength = Math.min(item.fixedLength + 1, this.dimens.width);
|
||||
|
@ -270,7 +282,21 @@ FullMenuView.prototype.redraw = function () {
|
|||
|
||||
this.cachePositions();
|
||||
|
||||
// In case we get in a bad state, try to recover
|
||||
if (this.currentPage < 0) {
|
||||
this.currentPage = 0;
|
||||
}
|
||||
|
||||
if (this.items.length) {
|
||||
if (
|
||||
this.currentPage > this.pages.length ||
|
||||
!_.isObject(this.pages[this.currentPage])
|
||||
) {
|
||||
this.client.log.warn(
|
||||
{ currentPage: this.currentPage, pagesLength: this.pages.length },
|
||||
'Invalid state! in full menu redraw'
|
||||
);
|
||||
} else {
|
||||
for (
|
||||
let i = this.pages[this.currentPage].start;
|
||||
i <= this.pages[this.currentPage].end;
|
||||
|
@ -280,6 +306,7 @@ FullMenuView.prototype.redraw = function () {
|
|||
this.drawItem(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
FullMenuView.prototype.setHeight = function (height) {
|
||||
|
@ -358,6 +385,10 @@ FullMenuView.prototype.setItems = function (items) {
|
|||
this.oldDimens = Object.assign({}, this.dimens);
|
||||
}
|
||||
|
||||
// Reset the page on new items
|
||||
this.currentPage = 0;
|
||||
this.focusedItemIndex = 0;
|
||||
|
||||
FullMenuView.super_.prototype.setItems.call(this, items);
|
||||
|
||||
this.positionCacheExpired = true;
|
||||
|
@ -377,6 +408,15 @@ FullMenuView.prototype.focusNext = function () {
|
|||
this.clearPage();
|
||||
this.focusedItemIndex = 0;
|
||||
this.currentPage = 0;
|
||||
} else {
|
||||
if (
|
||||
this.currentPage > this.pages.length ||
|
||||
!_.isObject(this.pages[this.currentPage])
|
||||
) {
|
||||
this.client.log.warn(
|
||||
{ currentPage: this.currentPage, pagesLength: this.pages.length },
|
||||
'Invalid state in focusNext for full menu view'
|
||||
);
|
||||
} else {
|
||||
this.focusedItemIndex++;
|
||||
if (this.focusedItemIndex > this.pages[this.currentPage].end) {
|
||||
|
@ -384,6 +424,7 @@ FullMenuView.prototype.focusNext = function () {
|
|||
this.currentPage++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.redraw();
|
||||
|
||||
|
@ -397,11 +438,21 @@ FullMenuView.prototype.focusPrevious = function () {
|
|||
this.currentPage = this.pages.length - 1;
|
||||
} else {
|
||||
this.focusedItemIndex--;
|
||||
if (
|
||||
this.currentPage > this.pages.length ||
|
||||
!_.isObject(this.pages[this.currentPage])
|
||||
) {
|
||||
this.client.log.warn(
|
||||
{ currentPage: this.currentPage, pagesLength: this.pages.length },
|
||||
'Bad focus state, ignoring call to focusPrevious.'
|
||||
);
|
||||
} else {
|
||||
if (this.focusedItemIndex < this.pages[this.currentPage].start) {
|
||||
this.clearPage();
|
||||
this.currentPage--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.redraw();
|
||||
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
const { Errors } = require('./enig_error.js');
|
||||
|
||||
// deps
|
||||
const { isString, isObject, truncate } = require('lodash');
|
||||
const httpsNoRedirects = require('node:https');
|
||||
const { https: httpsWithRedirects } = require('follow-redirects');
|
||||
const httpSignature = require('http-signature');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DefaultTimeoutMilliseconds = 5000;
|
||||
|
||||
exports.getJson = getJson;
|
||||
exports.postJson = postJson;
|
||||
|
||||
function getJson(url, options, cb) {
|
||||
options = Object.assign({}, { method: 'GET' }, options);
|
||||
|
||||
return _makeRequest(url, options, (err, body, res) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (Array.isArray(options.validContentTypes)) {
|
||||
const contentType = res.headers['content-type'] || '';
|
||||
if (
|
||||
!options.validContentTypes.some(ct => {
|
||||
return contentType.startsWith(ct);
|
||||
})
|
||||
) {
|
||||
return cb(Errors.HttpError(`Invalid Content-Type: ${contentType}`));
|
||||
}
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch (e) {
|
||||
return cb(e);
|
||||
}
|
||||
|
||||
return cb(null, parsed, res);
|
||||
});
|
||||
}
|
||||
|
||||
function postJson(url, json, options, cb) {
|
||||
if (!isString(json)) {
|
||||
json = JSON.stringify(json);
|
||||
}
|
||||
|
||||
options = Object.assign({}, { method: 'POST', body: json }, options);
|
||||
if (
|
||||
!options.headers ||
|
||||
!Object.keys(options.headers).find(h => h.toLowerCase() === 'content-type')
|
||||
) {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
return _makeRequest(url, options, cb);
|
||||
}
|
||||
|
||||
function _makeRequest(url, options, cb) {
|
||||
options = Object.assign({}, { timeout: DefaultTimeoutMilliseconds }, options);
|
||||
|
||||
if (options.body) {
|
||||
options.headers['Content-Length'] = Buffer.from(options.body).length;
|
||||
|
||||
if (options?.sign?.headers?.includes('digest')) {
|
||||
options.headers['Digest'] =
|
||||
'SHA-256=' +
|
||||
crypto.createHash('sha256').update(options.body).digest('base64');
|
||||
}
|
||||
}
|
||||
|
||||
let cbCalled = false;
|
||||
const cbWrapper = (e, b, r) => {
|
||||
if (!cbCalled) {
|
||||
cbCalled = true;
|
||||
return cb(e, b, r);
|
||||
}
|
||||
};
|
||||
|
||||
let https;
|
||||
if (options.method === 'POST' || options.sign) {
|
||||
https = httpsNoRedirects;
|
||||
} else {
|
||||
https = httpsWithRedirects;
|
||||
}
|
||||
|
||||
const req = https.request(url, options, res => {
|
||||
let body = [];
|
||||
res.on('data', d => {
|
||||
body.push(d);
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
body = Buffer.concat(body).toString();
|
||||
|
||||
if (res.statusCode < 200 || res.statusCode > 299) {
|
||||
return cbWrapper(
|
||||
Errors.HttpError(
|
||||
`URL ${url} HTTP error ${res.statusCode}: ${truncate(body, {
|
||||
length: 128,
|
||||
})}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return cbWrapper(null, body, res);
|
||||
});
|
||||
});
|
||||
|
||||
if (isObject(options.sign)) {
|
||||
try {
|
||||
httpSignature.sign(req, options.sign);
|
||||
} catch (e) {
|
||||
req.destroy(Errors.Invalid(`Invalid signing material: ${e}`));
|
||||
}
|
||||
}
|
||||
|
||||
req.on('error', err => {
|
||||
return cbWrapper(err);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy(Errors.Timeout('Timeout making HTTP request'));
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(options.body);
|
||||
}
|
||||
req.end();
|
||||
}
|
|
@ -6,6 +6,7 @@ const logger = require('./logger.js');
|
|||
|
||||
// deps
|
||||
const async = require('async');
|
||||
const isFunction = require('lodash/isFunction');
|
||||
|
||||
const listeningServers = {}; // packageName -> info
|
||||
|
||||
|
@ -27,33 +28,57 @@ function getServer(packageName) {
|
|||
|
||||
function startListening(cb) {
|
||||
const moduleUtil = require('./module_util.js'); // late load so we get Config
|
||||
const cats = moduleUtil.moduleCategories;
|
||||
|
||||
async.each(
|
||||
['login', 'content', 'chat'],
|
||||
[cats.Login, cats.Content, cats.Chat],
|
||||
(category, next) => {
|
||||
moduleUtil.loadModulesForCategory(
|
||||
`${category}Servers`,
|
||||
(module, nextModule) => {
|
||||
const moduleInst = new module.getModule();
|
||||
try {
|
||||
moduleInst.createServer(err => {
|
||||
if (err) {
|
||||
return nextModule(err);
|
||||
}
|
||||
|
||||
moduleInst.listen(err => {
|
||||
if (err) {
|
||||
return nextModule(err);
|
||||
}
|
||||
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return moduleInst.createServer(callback);
|
||||
},
|
||||
callback => {
|
||||
listeningServers[module.moduleInfo.packageName] = {
|
||||
instance: moduleInst,
|
||||
info: module.moduleInfo,
|
||||
};
|
||||
|
||||
if (!isFunction(moduleInst.beforeListen)) {
|
||||
return callback(null);
|
||||
}
|
||||
moduleInst.beforeListen(err => {
|
||||
return callback(err);
|
||||
});
|
||||
},
|
||||
callback => {
|
||||
return moduleInst.listen(callback);
|
||||
},
|
||||
callback => {
|
||||
if (!isFunction(moduleInst.afterListen)) {
|
||||
return callback(null);
|
||||
}
|
||||
moduleInst.afterListen(err => {
|
||||
return callback(err);
|
||||
});
|
||||
},
|
||||
],
|
||||
err => {
|
||||
if (err) {
|
||||
delete listeningServers[
|
||||
module.moduleInfo.packageName
|
||||
];
|
||||
return nextModule(err);
|
||||
}
|
||||
|
||||
return nextModule(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
logger.log.error(e, 'Exception caught creating server!');
|
||||
return nextModule(e);
|
||||
|
|
|
@ -27,20 +27,26 @@ module.exports = class Log {
|
|||
logStreams.push(Config.logging.rotatingFile);
|
||||
}
|
||||
|
||||
const serializers = Log.standardSerializers();
|
||||
|
||||
this.log = bunyan.createLogger({
|
||||
name: 'ENiGMA½',
|
||||
streams: logStreams,
|
||||
serializers: serializers,
|
||||
});
|
||||
}
|
||||
|
||||
static standardSerializers() {
|
||||
const serializers = {
|
||||
err: bunyan.stdSerializers.err, // handle 'err' fields with stack/etc.
|
||||
};
|
||||
|
||||
// try to remove sensitive info by default, e.g. 'password' fields
|
||||
['formData', 'formValue'].forEach(keyName => {
|
||||
['formData', 'formValue', 'user'].forEach(keyName => {
|
||||
serializers[keyName] = fd => Log.hideSensitive(fd);
|
||||
});
|
||||
|
||||
this.log = bunyan.createLogger({
|
||||
name: 'ENiGMA½ BBS',
|
||||
streams: logStreams,
|
||||
serializers: serializers,
|
||||
});
|
||||
return serializers;
|
||||
}
|
||||
|
||||
static checkLogPath(logPath) {
|
||||
|
@ -65,9 +71,10 @@ module.exports = class Log {
|
|||
//
|
||||
return JSON.parse(
|
||||
JSON.stringify(obj).replace(
|
||||
/"(password|passwordConfirm|key|authCode)"\s?:\s?"([^"]+)"/,
|
||||
(match, valueName) => {
|
||||
return `"${valueName}":"********"`;
|
||||
// note that we match against key names here
|
||||
/"(password|passwordConfirm|key|authCode)"\s?:\s?"([^"]+)"/g,
|
||||
(match, keyName) => {
|
||||
return `"${keyName}":"********"`;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
|
@ -1,13 +1,24 @@
|
|||
/* jslint node: true */
|
||||
'use strict';
|
||||
|
||||
const EnigmaAssert = require('./enigma_assert.js');
|
||||
const Address = require('./ftn_address.js');
|
||||
const Message = require('./message.js');
|
||||
const MessageConst = require('./message_const');
|
||||
const { getQuotePrefix } = require('./ftn_util');
|
||||
const Config = require('./config').get;
|
||||
|
||||
// deps
|
||||
const { get } = require('lodash');
|
||||
|
||||
exports.getAddressedToInfo = getAddressedToInfo;
|
||||
exports.setExternalAddressedToInfo = setExternalAddressedToInfo;
|
||||
exports.copyExternalAddressedToInfo = copyExternalAddressedToInfo;
|
||||
exports.messageInfoFromAddressedToInfo = messageInfoFromAddressedToInfo;
|
||||
exports.getQuotePrefixFromName = getQuotePrefixFromName;
|
||||
exports.getReplyToMessagePrefix = getReplyToMessagePrefix;
|
||||
|
||||
const EMAIL_REGEX =
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[?[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}]?)|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
|
||||
/*
|
||||
Input Output
|
||||
|
@ -21,6 +32,12 @@ const EMAIL_REGEX =
|
|||
43:20/100.2 { flavor : 'ftn', remote : '43:20/100.2' }
|
||||
foo@host.com { name : 'foo', flavor : 'email', remote : 'foo@host.com' }
|
||||
Bar <baz@foobar.net> { name : 'Bar', flavor : 'email', remote : 'baz@foobar.com' }
|
||||
@JoeUser@some.host.com { name : 'JoeUser', flavor : 'activitypub', remote 'JoeUser@some.host.com' }
|
||||
|
||||
Fields:
|
||||
- name : user/display name
|
||||
- flavor : remote flavor - FTN/etc.
|
||||
- remote : Address in remote format, if applicable
|
||||
*/
|
||||
function getAddressedToInfo(input) {
|
||||
input = input.trim();
|
||||
|
@ -30,29 +47,52 @@ function getAddressedToInfo(input) {
|
|||
if (firstAtPos < 0) {
|
||||
let addr = Address.fromString(input);
|
||||
if (Address.isValidAddress(addr)) {
|
||||
return { flavor: Message.AddressFlavor.FTN, remote: input };
|
||||
return {
|
||||
flavor: MessageConst.AddressFlavor.FTN,
|
||||
remote: input,
|
||||
};
|
||||
}
|
||||
|
||||
const lessThanPos = input.indexOf('<');
|
||||
if (lessThanPos < 0) {
|
||||
return { name: input, flavor: Message.AddressFlavor.Local };
|
||||
return {
|
||||
name: input,
|
||||
flavor: MessageConst.AddressFlavor.Local,
|
||||
};
|
||||
}
|
||||
|
||||
const greaterThanPos = input.indexOf('>');
|
||||
if (greaterThanPos < lessThanPos) {
|
||||
return { name: input, flavor: Message.AddressFlavor.Local };
|
||||
return {
|
||||
name: input,
|
||||
flavor: MessageConst.AddressFlavor.Local,
|
||||
};
|
||||
}
|
||||
|
||||
addr = Address.fromString(input.slice(lessThanPos + 1, greaterThanPos));
|
||||
if (Address.isValidAddress(addr)) {
|
||||
return {
|
||||
name: input.slice(0, lessThanPos).trim(),
|
||||
flavor: Message.AddressFlavor.FTN,
|
||||
flavor: MessageConst.AddressFlavor.FTN,
|
||||
remote: addr.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
return { name: input, flavor: Message.AddressFlavor.Local };
|
||||
return { name: input, flavor: MessageConst.AddressFlavor.Local };
|
||||
}
|
||||
|
||||
if (firstAtPos === 0) {
|
||||
const secondAtPos = input.indexOf('@', 1);
|
||||
if (secondAtPos > 0) {
|
||||
const m = input.slice(1).match(EMAIL_REGEX);
|
||||
if (m) {
|
||||
return {
|
||||
name: input.slice(1, secondAtPos),
|
||||
flavor: MessageConst.AddressFlavor.ActivityPub,
|
||||
remote: input.slice(firstAtPos),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lessThanPos = input.indexOf('<');
|
||||
|
@ -63,36 +103,107 @@ function getAddressedToInfo(input) {
|
|||
if (m) {
|
||||
return {
|
||||
name: input.slice(0, lessThanPos).trim(),
|
||||
flavor: Message.AddressFlavor.Email,
|
||||
flavor: MessageConst.AddressFlavor.Email,
|
||||
remote: addr,
|
||||
};
|
||||
}
|
||||
|
||||
return { name: input, flavor: Message.AddressFlavor.Local };
|
||||
return {
|
||||
name: input,
|
||||
flavor: MessageConst.AddressFlavor.Local,
|
||||
};
|
||||
}
|
||||
|
||||
let m = input.match(EMAIL_REGEX);
|
||||
if (m) {
|
||||
return {
|
||||
name: input.slice(0, firstAtPos),
|
||||
flavor: Message.AddressFlavor.Email,
|
||||
flavor: MessageConst.AddressFlavor.Email,
|
||||
remote: input,
|
||||
};
|
||||
}
|
||||
|
||||
let addr = Address.fromString(input); // 5D?
|
||||
if (Address.isValidAddress(addr)) {
|
||||
return { flavor: Message.AddressFlavor.FTN, remote: addr.toString() };
|
||||
return {
|
||||
flavor: MessageConst.AddressFlavor.FTN,
|
||||
remote: addr.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
addr = Address.fromString(input.slice(firstAtPos + 1).trim());
|
||||
if (Address.isValidAddress(addr)) {
|
||||
return {
|
||||
name: input.slice(0, firstAtPos).trim(),
|
||||
flavor: Message.AddressFlavor.FTN,
|
||||
flavor: MessageConst.AddressFlavor.FTN,
|
||||
remote: addr.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
return { name: input, flavor: Message.AddressFlavor.Local };
|
||||
return { name: input, flavor: MessageConst.AddressFlavor.Local };
|
||||
}
|
||||
|
||||
/// returns true if it's an external address
|
||||
function setExternalAddressedToInfo(addressInfo, message) {
|
||||
const isValidAddressInfo = () => {
|
||||
return addressInfo.name.length > 1 && addressInfo.remote.length > 1;
|
||||
};
|
||||
|
||||
switch (addressInfo.flavor) {
|
||||
case MessageConst.AddressFlavor.FTN:
|
||||
case MessageConst.AddressFlavor.Email:
|
||||
case MessageConst.AddressFlavor.QWK:
|
||||
case MessageConst.AddressFlavor.NNTP:
|
||||
case MessageConst.AddressFlavor.ActivityPub:
|
||||
EnigmaAssert(isValidAddressInfo());
|
||||
|
||||
message.setRemoteToUser(addressInfo.remote);
|
||||
message.setExternalFlavor(addressInfo.flavor);
|
||||
message.toUserName = addressInfo.name;
|
||||
return true;
|
||||
|
||||
default:
|
||||
case MessageConst.AddressFlavor.Local:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyExternalAddressedToInfo(fromMessage, toMessage) {
|
||||
const sm = MessageConst.SystemMetaNames;
|
||||
toMessage.setRemoteToUser(fromMessage.meta.System[sm.RemoteFromUser]);
|
||||
toMessage.setExternalFlavor(fromMessage.meta.System[sm.ExternalFlavor]);
|
||||
}
|
||||
|
||||
function messageInfoFromAddressedToInfo(addressInfo) {
|
||||
switch (addressInfo.flavor) {
|
||||
case MessageConst.AddressFlavor.ActivityPub: {
|
||||
const config = Config();
|
||||
const maxMessageLength = get(config, 'activityPub.maxMessageLength', 500);
|
||||
const autoSignatures = get(config, 'activityPub.autoSignatures', false);
|
||||
|
||||
// Additionally, it's ot necessary to supply a subject
|
||||
// (aka summary) with a 'Note' Activity
|
||||
return { subjectOptional: true, maxMessageLength, autoSignatures };
|
||||
}
|
||||
|
||||
default:
|
||||
// autoSignatures: null = varies by additional config
|
||||
return { subjectOptional: false, maxMessageLength: 0, autoSignatures: null };
|
||||
}
|
||||
}
|
||||
|
||||
function getQuotePrefixFromName(name) {
|
||||
const addressInfo = getAddressedToInfo(name);
|
||||
return getQuotePrefix(addressInfo.name || name);
|
||||
}
|
||||
|
||||
function getReplyToMessagePrefix(name) {
|
||||
const addressInfo = getAddressedToInfo(name);
|
||||
|
||||
// currently only ActivityPub
|
||||
if (addressInfo.flavor === MessageConst.AddressFlavor.ActivityPub) {
|
||||
return `@${addressInfo.name} `;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ const MultiLineEditTextView =
|
|||
const Errors = require('../core/enig_error.js').Errors;
|
||||
const { getPredefinedMCIValue } = require('../core/predefined_mci.js');
|
||||
const EnigAssert = require('./enigma_assert');
|
||||
const { pipeToAnsi } = require('./color_codes.js');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
|
@ -36,7 +37,6 @@ const MenuFlags = {
|
|||
|
||||
exports.MenuFlags = MenuFlags;
|
||||
|
||||
|
||||
exports.MenuModule = class MenuModule extends PluginModule {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
@ -67,7 +67,9 @@ exports.MenuModule = class MenuModule extends PluginModule {
|
|||
|
||||
setMergedFlag(flag) {
|
||||
this.menuConfig.config.menuFlags.push(flag);
|
||||
this.menuConfig.config.menuFlags = [...new Set([...this.menuConfig.config.menuFlags, MenuFlags.MergeFlags])];
|
||||
this.menuConfig.config.menuFlags = [
|
||||
...new Set([...this.menuConfig.config.menuFlags, MenuFlags.MergeFlags]),
|
||||
];
|
||||
}
|
||||
|
||||
static get InterruptTypes() {
|
||||
|
@ -654,6 +656,14 @@ exports.MenuModule = class MenuModule extends PluginModule {
|
|||
this.client.term.rawWrite(ansi.resetScreen());
|
||||
}
|
||||
|
||||
if (!_.has(config.art, name)) {
|
||||
const artKeys = _.keys(config.art);
|
||||
this.client.log.warn(
|
||||
{ requestedArtName: name, availableArtKeys: artKeys },
|
||||
'Art name is not set! Check configuration for typos.'
|
||||
);
|
||||
}
|
||||
|
||||
theme.displayThemedAsset(
|
||||
config.art[name],
|
||||
this.client,
|
||||
|
@ -765,10 +775,25 @@ exports.MenuModule = class MenuModule extends PluginModule {
|
|||
const format = config[view.key];
|
||||
const text = stringFormat(format, fmtObj);
|
||||
|
||||
if (options.appendMultiLine && view instanceof MultiLineEditTextView) {
|
||||
if (view instanceof MultiLineEditTextView) {
|
||||
if (options.appendMultiLine) {
|
||||
view.addText(text);
|
||||
} else {
|
||||
if (view.getData() != text) {
|
||||
if (options.pipeSupport) {
|
||||
const ansi = pipeToAnsi(text, this.client);
|
||||
if (view.getData() !== ansi) {
|
||||
view.setAnsi(ansi);
|
||||
} else {
|
||||
view.redraw();
|
||||
}
|
||||
} else if (view.getData() !== text) {
|
||||
view.setText(text);
|
||||
} else {
|
||||
view.redraw();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (view.getData() !== text) {
|
||||
view.setText(text);
|
||||
} else {
|
||||
view.redraw();
|
||||
|
|
|
@ -75,7 +75,7 @@ module.exports = class MenuStack {
|
|||
|
||||
this.pop().instance.leave(); // leave & remove current
|
||||
|
||||
const previousModuleInfo = this.pop(); // get previous
|
||||
const previousModuleInfo = this.pop(); // get previous; we'll re-create a instance
|
||||
|
||||
if (previousModuleInfo) {
|
||||
const opts = {
|
||||
|
@ -93,15 +93,13 @@ module.exports = class MenuStack {
|
|||
}
|
||||
|
||||
goto(name, options, cb) {
|
||||
const currentModuleInfo = this.top();
|
||||
|
||||
if (!cb && _.isFunction(options)) {
|
||||
cb = options;
|
||||
options = {};
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
const self = this;
|
||||
|
||||
const currentModuleInfo = this.top();
|
||||
|
||||
if (currentModuleInfo && name === currentModuleInfo.name) {
|
||||
if (cb) {
|
||||
|
@ -117,10 +115,13 @@ module.exports = class MenuStack {
|
|||
|
||||
const loadOpts = {
|
||||
name: name,
|
||||
client: self.client,
|
||||
client: this.client,
|
||||
};
|
||||
|
||||
if (currentModuleInfo && currentModuleInfo.menuFlags.includes(MenuFlags.ForwardArgs)) {
|
||||
if (
|
||||
currentModuleInfo &&
|
||||
currentModuleInfo.menuFlags.includes(MenuFlags.ForwardArgs)
|
||||
) {
|
||||
loadOpts.extraArgs = currentModuleInfo.extraArgs;
|
||||
} else {
|
||||
loadOpts.extraArgs = options.extraArgs || _.get(options, 'formData.value');
|
||||
|
@ -129,10 +130,10 @@ module.exports = class MenuStack {
|
|||
|
||||
loadMenu(loadOpts, (err, modInst) => {
|
||||
if (err) {
|
||||
const errCb = cb || self.client.defaultHandlerMissingMod();
|
||||
const errCb = cb || this.client.defaultHandlerMissingMod();
|
||||
errCb(err);
|
||||
} else {
|
||||
self.client.log.debug({ menuName: name }, 'Goto menu module');
|
||||
this.client.log.debug({ menuName: name }, 'Goto menu module');
|
||||
|
||||
if (!this.client.acs.hasMenuModuleAccess(modInst)) {
|
||||
if (cb) {
|
||||
|
@ -168,11 +169,11 @@ module.exports = class MenuStack {
|
|||
currentModuleInfo.instance.leave();
|
||||
|
||||
if (currentModuleInfo.menuFlags.includes(MenuFlags.NoHistory)) {
|
||||
this.pop().instance.leave(); // leave & remove current from stack
|
||||
this.pop();
|
||||
}
|
||||
}
|
||||
|
||||
self.push({
|
||||
this.push({
|
||||
name: name,
|
||||
instance: modInst,
|
||||
extraArgs: loadOpts.extraArgs,
|
||||
|
@ -184,8 +185,8 @@ module.exports = class MenuStack {
|
|||
modInst.restoreSavedState(options.savedState);
|
||||
}
|
||||
|
||||
if (self.client.log.level() <= bunyan.TRACE) {
|
||||
const stackEntries = self.stack.map(stackEntry => {
|
||||
if (this.client.log.level() <= bunyan.TRACE) {
|
||||
const stackEntries = this.stack.map(stackEntry => {
|
||||
let name = stackEntry.name;
|
||||
if (stackEntry.instance.menuConfig.config.menuFlags.length > 0) {
|
||||
name += ` (${stackEntry.instance.menuConfig.config.menuFlags.join(
|
||||
|
@ -195,7 +196,7 @@ module.exports = class MenuStack {
|
|||
return name;
|
||||
});
|
||||
|
||||
self.client.log.trace({ stack: stackEntries }, 'Updated menu stack');
|
||||
this.client.log.trace({ stack: stackEntries }, 'Updated menu stack');
|
||||
}
|
||||
|
||||
modInst.enter();
|
||||
|
|
261
core/message.js
261
core/message.js
|
@ -3,14 +3,14 @@
|
|||
|
||||
const msgDb = require('./database.js').dbs.message;
|
||||
const wordWrapText = require('./word_wrap.js').wordWrapText;
|
||||
const ftnUtil = require('./ftn_util.js');
|
||||
const createNamedUUID = require('./uuid_util.js').createNamedUUID;
|
||||
const Errors = require('./enig_error.js').Errors;
|
||||
const ANSI = require('./ansi_term.js');
|
||||
const { sanitizeString, getISOTimestampString } = require('./database.js');
|
||||
|
||||
const { isCP437Encodable } = require('./cp437util');
|
||||
const { containsNonLatinCodepoints } = require('./string_util');
|
||||
const MessageConst = require('./message_const');
|
||||
const { getQuotePrefixFromName } = require('./mail_util');
|
||||
|
||||
const {
|
||||
isAnsi,
|
||||
|
@ -33,73 +33,6 @@ const ENIGMA_MESSAGE_UUID_NAMESPACE = uuidParse.parse(
|
|||
'154506df-1df8-46b9-98f8-ebb5815baaf8'
|
||||
);
|
||||
|
||||
const WELL_KNOWN_AREA_TAGS = {
|
||||
Invalid: '',
|
||||
Private: 'private_mail',
|
||||
Bulletin: 'local_bulletin',
|
||||
};
|
||||
|
||||
const SYSTEM_META_NAMES = {
|
||||
LocalToUserID: 'local_to_user_id',
|
||||
LocalFromUserID: 'local_from_user_id',
|
||||
StateFlags0: 'state_flags0', // See Message.StateFlags0
|
||||
ExplicitEncoding: 'explicit_encoding', // Explicitly set encoding when exporting/etc.
|
||||
ExternalFlavor: 'external_flavor', // "Flavor" of message - imported from or to be exported to. See Message.AddressFlavor
|
||||
RemoteToUser: 'remote_to_user', // Opaque value depends on external system, e.g. FTN address
|
||||
RemoteFromUser: 'remote_from_user', // Opaque value depends on external system, e.g. FTN address
|
||||
};
|
||||
|
||||
// Types for Message.SystemMetaNames.ExternalFlavor meta
|
||||
const ADDRESS_FLAVOR = {
|
||||
Local: 'local', // local / non-remote addressing
|
||||
FTN: 'ftn', // FTN style
|
||||
Email: 'email', // From email
|
||||
QWK: 'qwk', // QWK packet
|
||||
NNTP: 'nntp', // NNTP article POST; often a email address
|
||||
};
|
||||
|
||||
const STATE_FLAGS0 = {
|
||||
None: 0x00000000,
|
||||
Imported: 0x00000001, // imported from foreign system
|
||||
Exported: 0x00000002, // exported to foreign system
|
||||
};
|
||||
|
||||
// :TODO: these should really live elsewhere...
|
||||
const FTN_PROPERTY_NAMES = {
|
||||
// packet header oriented
|
||||
FtnOrigNode: 'ftn_orig_node',
|
||||
FtnDestNode: 'ftn_dest_node',
|
||||
// :TODO: rename these to ftn_*_net vs network - ensure things won't break, may need mapping
|
||||
FtnOrigNetwork: 'ftn_orig_network',
|
||||
FtnDestNetwork: 'ftn_dest_network',
|
||||
FtnAttrFlags: 'ftn_attr_flags',
|
||||
FtnCost: 'ftn_cost',
|
||||
FtnOrigZone: 'ftn_orig_zone',
|
||||
FtnDestZone: 'ftn_dest_zone',
|
||||
FtnOrigPoint: 'ftn_orig_point',
|
||||
FtnDestPoint: 'ftn_dest_point',
|
||||
|
||||
// message header oriented
|
||||
FtnMsgOrigNode: 'ftn_msg_orig_node',
|
||||
FtnMsgDestNode: 'ftn_msg_dest_node',
|
||||
FtnMsgOrigNet: 'ftn_msg_orig_net',
|
||||
FtnMsgDestNet: 'ftn_msg_dest_net',
|
||||
|
||||
FtnAttribute: 'ftn_attribute',
|
||||
|
||||
FtnTearLine: 'ftn_tear_line', // http://ftsc.org/docs/fts-0004.001
|
||||
FtnOrigin: 'ftn_origin', // http://ftsc.org/docs/fts-0004.001
|
||||
FtnArea: 'ftn_area', // http://ftsc.org/docs/fts-0004.001
|
||||
FtnSeenBy: 'ftn_seen_by', // http://ftsc.org/docs/fts-0004.001
|
||||
};
|
||||
|
||||
const QWKPropertyNames = {
|
||||
MessageNumber: 'qwk_msg_num',
|
||||
MessageStatus: 'qwk_msg_status', // See http://wiki.synchro.net/ref:qwk for a decent list
|
||||
ConferenceNumber: 'qwk_conf_num',
|
||||
InReplyToNum: 'qwk_in_reply_to_num', // note that we prefer the 'InReplyToMsgId' kludge if available
|
||||
};
|
||||
|
||||
// :TODO: this is a ugly hack due to bad variable names - clean it up & just _.camelCase(k)!
|
||||
const MESSAGE_ROW_MAP = {
|
||||
reply_to_message_id: 'replyToMsgId',
|
||||
|
@ -158,8 +91,30 @@ module.exports = class Message {
|
|||
return Message.isPrivateAreaTag(this.areaTag);
|
||||
}
|
||||
|
||||
isPublic() {
|
||||
return !this.isPrivate();
|
||||
}
|
||||
|
||||
isFromRemoteUser() {
|
||||
return null !== _.get(this, 'meta.System.remote_from_user', null);
|
||||
return null !== this.getRemoteFromUser();
|
||||
}
|
||||
|
||||
setRemoteFromUser(remoteFrom) {
|
||||
this.meta[Message.WellKnownMetaCategories.System][
|
||||
Message.SystemMetaNames.RemoteFromUser
|
||||
] = remoteFrom;
|
||||
}
|
||||
|
||||
getRemoteFromUser() {
|
||||
return _.get(
|
||||
this,
|
||||
[
|
||||
'meta',
|
||||
Message.WellKnownMetaCategories.System,
|
||||
Message.SystemMetaNames.RemoteFromUser,
|
||||
],
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
isCP437Encodable() {
|
||||
|
@ -208,28 +163,36 @@ module.exports = class Message {
|
|||
return (this.isPrivate() && user.userId === messageLocalUserId) || user.isSysOp();
|
||||
}
|
||||
|
||||
static get WellKnownMetaCategories() {
|
||||
return MessageConst.WellKnownMetaCategories;
|
||||
}
|
||||
|
||||
static get WellKnownAreaTags() {
|
||||
return WELL_KNOWN_AREA_TAGS;
|
||||
return MessageConst.WellKnownAreaTags;
|
||||
}
|
||||
|
||||
static get SystemMetaNames() {
|
||||
return SYSTEM_META_NAMES;
|
||||
return MessageConst.SystemMetaNames;
|
||||
}
|
||||
|
||||
static get AddressFlavor() {
|
||||
return ADDRESS_FLAVOR;
|
||||
return MessageConst.AddressFlavor;
|
||||
}
|
||||
|
||||
static get StateFlags0() {
|
||||
return STATE_FLAGS0;
|
||||
return MessageConst.StateFlags0;
|
||||
}
|
||||
|
||||
static get FtnPropertyNames() {
|
||||
return FTN_PROPERTY_NAMES;
|
||||
return MessageConst.FtnPropertyNames;
|
||||
}
|
||||
|
||||
static get QWKPropertyNames() {
|
||||
return QWKPropertyNames;
|
||||
return MessageConst.QWKPropertyNames;
|
||||
}
|
||||
|
||||
static get ActivityPubPropertyNames() {
|
||||
return MessageConst.ActivityPubPropertyNames;
|
||||
}
|
||||
|
||||
setLocalToUserId(userId) {
|
||||
|
@ -242,16 +205,29 @@ module.exports = class Message {
|
|||
this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId;
|
||||
}
|
||||
|
||||
getLocalFromUserId() {
|
||||
let id = _.get(this, 'meta.System.local_from_user_id', 0);
|
||||
return parseInt(id);
|
||||
}
|
||||
|
||||
setRemoteToUser(remoteTo) {
|
||||
this.meta.System = this.meta.System || {};
|
||||
this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo;
|
||||
}
|
||||
|
||||
getRemoteToUser() {
|
||||
return _.get(this, 'meta.System.remote_to_user');
|
||||
}
|
||||
|
||||
setExternalFlavor(flavor) {
|
||||
this.meta.System = this.meta.System || {};
|
||||
this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor;
|
||||
}
|
||||
|
||||
getAddressFlavor() {
|
||||
return _.get(this, 'meta.System.external_flavor', Message.AddressFlavor.Local);
|
||||
}
|
||||
|
||||
static createMessageUUID(areaTag, modTimestamp, subject, body) {
|
||||
assert(_.isString(areaTag));
|
||||
assert(_.isDate(modTimestamp) || moment.isMoment(modTimestamp));
|
||||
|
@ -732,6 +708,17 @@ module.exports = class Message {
|
|||
);
|
||||
}
|
||||
|
||||
static deleteByMessageUuid(messageUuid, cb) {
|
||||
msgDb.run(
|
||||
`DELETE FROM message
|
||||
WHERE message_uuid = ?;`,
|
||||
[messageUuid],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
persistMetaValue(category, name, value, transOrDb, cb) {
|
||||
if (!_.isFunction(cb) && _.isFunction(transOrDb)) {
|
||||
cb = transOrDb;
|
||||
|
@ -762,6 +749,34 @@ module.exports = class Message {
|
|||
);
|
||||
}
|
||||
|
||||
updateMetaValue(category, name, value, transOrDb, cb) {
|
||||
if (!_.isFunction(cb) && _.isFunction(transOrDb)) {
|
||||
cb = transOrDb;
|
||||
transOrDb = msgDb;
|
||||
}
|
||||
|
||||
const metaStmt = transOrDb.prepare(
|
||||
`REPLACE INTO message_meta (message_id, meta_category, meta_name, meta_value)
|
||||
VALUES (?, ?, ?, ?);`
|
||||
);
|
||||
|
||||
if (!_.isArray(value)) {
|
||||
value = [value];
|
||||
}
|
||||
|
||||
async.each(
|
||||
value,
|
||||
(v, next) => {
|
||||
metaStmt.run(this.messageId, category, name, v, err => {
|
||||
return next(err);
|
||||
});
|
||||
},
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
persist(cb) {
|
||||
const containsNonWhitespaceCharacterRegEx = /\S/;
|
||||
if (!containsNonWhitespaceCharacterRegEx.test(this.message)) {
|
||||
|
@ -872,6 +887,90 @@ module.exports = class Message {
|
|||
);
|
||||
}
|
||||
|
||||
update(cb) {
|
||||
if (!this.isValid()) {
|
||||
return cb(Errors.Invalid('Cannot update invalid message!'));
|
||||
}
|
||||
|
||||
if (!this.messageUuid) {
|
||||
return cb(Errors.Invalid("Cannot update without a valid 'messageUUID'"));
|
||||
}
|
||||
|
||||
const self = this;
|
||||
|
||||
async.waterfall(
|
||||
[
|
||||
callback => {
|
||||
return msgDb.beginTransaction(callback);
|
||||
},
|
||||
(trans, callback) => {
|
||||
trans.run(
|
||||
`REPLACE INTO message (area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||
[
|
||||
this.areaTag,
|
||||
this.messageUuid,
|
||||
this.replyToMsgId,
|
||||
this.toUserName,
|
||||
this.fromUserName,
|
||||
this.subject,
|
||||
this.message,
|
||||
getISOTimestampString(this.modTimestamp),
|
||||
],
|
||||
function inserted(err) {
|
||||
// use non-arrow function for 'this' scope
|
||||
if (!err) {
|
||||
self.messageId = this.lastID;
|
||||
}
|
||||
|
||||
return callback(err, trans);
|
||||
}
|
||||
);
|
||||
},
|
||||
(trans, callback) => {
|
||||
if (!this.meta) {
|
||||
return callback(null, trans);
|
||||
}
|
||||
|
||||
async.each(
|
||||
Object.keys(this.meta),
|
||||
(category, nextCat) => {
|
||||
async.each(
|
||||
Object.keys(this.meta[category]),
|
||||
(name, nextName) => {
|
||||
this.updateMetaValue(
|
||||
category,
|
||||
name,
|
||||
this.meta[category][name],
|
||||
trans,
|
||||
err => {
|
||||
return nextName(err);
|
||||
}
|
||||
);
|
||||
},
|
||||
err => {
|
||||
return nextCat(err);
|
||||
}
|
||||
);
|
||||
},
|
||||
err => {
|
||||
return callback(err, trans);
|
||||
}
|
||||
);
|
||||
},
|
||||
],
|
||||
(err, trans) => {
|
||||
if (trans) {
|
||||
trans[err ? 'rollback' : 'commit'](transErr => {
|
||||
return cb(err ? err : transErr, self.messageId);
|
||||
});
|
||||
} else {
|
||||
return cb(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
deleteMessage(requestingUser, cb) {
|
||||
if (!this.userHasDeleteRights(requestingUser)) {
|
||||
return cb(
|
||||
|
@ -889,11 +988,13 @@ module.exports = class Message {
|
|||
);
|
||||
}
|
||||
|
||||
// :TODO: FTN stuff doesn't have any business here
|
||||
getFTNQuotePrefix(source) {
|
||||
_getQuotePrefix(source) {
|
||||
source = source || 'fromUserName';
|
||||
|
||||
return ftnUtil.getQuotePrefix(this[source]);
|
||||
// grab out the name member, so we don't try to build
|
||||
// quote prefixes such as "@N" for "@NuSkooler@some.host", etc.
|
||||
const userName = this[source];
|
||||
return getQuotePrefixFromName(userName);
|
||||
}
|
||||
|
||||
static getTearLinePosition(input) {
|
||||
|
@ -928,7 +1029,7 @@ module.exports = class Message {
|
|||
|
||||
*/
|
||||
const quotePrefix = options.includePrefix
|
||||
? this.getFTNQuotePrefix(options.prefixSource || 'fromUserName')
|
||||
? this._getQuotePrefix(options.prefixSource || 'fromUserName')
|
||||
: '';
|
||||
|
||||
function getWrapped(text, extraPrefix) {
|
||||
|
|
|
@ -11,6 +11,13 @@ const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs;
|
|||
const UserProps = require('./user_property.js');
|
||||
const StatLog = require('./stat_log.js');
|
||||
const SysProps = require('./system_property.js');
|
||||
const {
|
||||
SystemInternalConfTags,
|
||||
WellKnownConfTags,
|
||||
WellKnownAreaTags,
|
||||
} = require('./message_const');
|
||||
const Collection = require('./activitypub/collection');
|
||||
const { Collections } = require('./activitypub/const');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
|
@ -93,9 +100,9 @@ function getAvailableMessageConferences(client, options) {
|
|||
|
||||
assert(client || true === options.noClient);
|
||||
|
||||
// perform ACS check per conf & omit system_internal if desired
|
||||
// perform ACS check per conf & omit "System Internal" if desired
|
||||
return _.omitBy(Config().messageConferences, (conf, confTag) => {
|
||||
if (!options.includeSystemInternal && 'system_internal' === confTag) {
|
||||
if (!options.includeSystemInternal && SystemInternalConfTags.includes(confTag)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -178,7 +185,7 @@ function getDefaultMessageConferenceTag(client, disableAcsCheck) {
|
|||
//
|
||||
// It's possible that we end up with nothing here!
|
||||
//
|
||||
// Note that built in 'system_internal' is always ommited here
|
||||
// Note that built in "System Internal" are always omitted here
|
||||
//
|
||||
const config = Config();
|
||||
let defaultConf = _.findKey(config.messageConferences, o => o.default);
|
||||
|
@ -192,7 +199,7 @@ function getDefaultMessageConferenceTag(client, disableAcsCheck) {
|
|||
// just use anything we can
|
||||
defaultConf = _.findKey(config.messageConferences, (conf, confTag) => {
|
||||
return (
|
||||
'system_internal' !== confTag &&
|
||||
!SystemInternalConfTags.includes(confTag) &&
|
||||
(true === disableAcsCheck || client.acs.hasMessageConfRead(conf))
|
||||
);
|
||||
});
|
||||
|
@ -512,8 +519,13 @@ function filterMessageListByReadACS(client, messageList) {
|
|||
});
|
||||
}
|
||||
|
||||
function getNewMessageCountInAreaForUser(userId, areaTag, cb) {
|
||||
getMessageAreaLastReadId(userId, areaTag, (err, lastMessageId) => {
|
||||
function getNewMessageCountInAreaForUser(
|
||||
user,
|
||||
areaTag,
|
||||
options = { addrToOnly: false },
|
||||
cb
|
||||
) {
|
||||
getMessageAreaLastReadId(user.userId, areaTag, (err, lastMessageId) => {
|
||||
lastMessageId = lastMessageId || 0;
|
||||
|
||||
const filter = {
|
||||
|
@ -523,7 +535,9 @@ function getNewMessageCountInAreaForUser(userId, areaTag, cb) {
|
|||
};
|
||||
|
||||
if (Message.isPrivateAreaTag(areaTag)) {
|
||||
filter.privateTagUserId = userId;
|
||||
filter.privateTagUserId = user.userId;
|
||||
} else if (options.addrToOnly) {
|
||||
filter.toUserName = user.username;
|
||||
}
|
||||
|
||||
Message.findMessages(filter, (err, count) => {
|
||||
|
@ -545,10 +559,11 @@ function getNewMessageCountAddressedToUser(client, cb) {
|
|||
areaTags,
|
||||
(areaTag, nextAreaTag) => {
|
||||
getMessageAreaLastReadId(client.user.userId, areaTag, (_, lastMessageId) => {
|
||||
lastMessageId = lastMessageId || 0;
|
||||
lastMessageId = lastMessageId || 0; // eslint-disable-line no-unused-vars
|
||||
getNewMessageCountInAreaForUser(
|
||||
client.user.userId,
|
||||
client.user,
|
||||
areaTag,
|
||||
{ addrToOnly: true },
|
||||
(err, count) => {
|
||||
newMessageCount += count;
|
||||
return nextAreaTag(err);
|
||||
|
@ -819,19 +834,70 @@ function trimMessageAreasScheduledEvent(args, cb) {
|
|||
return callback(null, areaInfos);
|
||||
},
|
||||
function trimGeneralAreas(areaInfos, callback) {
|
||||
const cbWrap = (e, t, c) => {
|
||||
if (e) {
|
||||
Log.warn({ error: e.message, type: t }, `Failed trimming (${t})`);
|
||||
}
|
||||
return c(null);
|
||||
};
|
||||
|
||||
const ApSharedAreaTag = Message.WellKnownAreaTags.ActivityPubShared;
|
||||
|
||||
// Clean up messages, and any associated ActivityPub 'SharedInbox'
|
||||
// Notes (ie: the source of said messages)
|
||||
async.each(
|
||||
areaInfos,
|
||||
(areaInfo, next) => {
|
||||
(areaInfo, nextArea) => {
|
||||
async.series(
|
||||
[
|
||||
next => {
|
||||
trimMessageAreaByMaxMessages(areaInfo, err => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
return cbWrap(err, 'Messages:MaxCount', next);
|
||||
});
|
||||
},
|
||||
next => {
|
||||
if (areaInfo.areaTag !== ApSharedAreaTag) {
|
||||
return next(null);
|
||||
}
|
||||
|
||||
Collection.removeByMaxCount(
|
||||
Collections.SharedInbox,
|
||||
areaInfo.maxMessages,
|
||||
err => {
|
||||
return cbWrap(
|
||||
err,
|
||||
'ActivityPubShared:MaxCount',
|
||||
next
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
next => {
|
||||
trimMessageAreaByMaxAgeDays(areaInfo, err => {
|
||||
return next(err);
|
||||
});
|
||||
return cbWrap(err, 'Messages:MaxAgeDays', next);
|
||||
});
|
||||
},
|
||||
next => {
|
||||
if (areaInfo.areaTag !== ApSharedAreaTag) {
|
||||
return next(null);
|
||||
}
|
||||
Collection.removeByMaxAgeDays(
|
||||
Collections.SharedInbox,
|
||||
areaInfo.maxAgeDays,
|
||||
err => {
|
||||
return cbWrap(
|
||||
err,
|
||||
'ActivityPubShared:MaxAgeDays',
|
||||
next
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
],
|
||||
err => {
|
||||
return nextArea(err);
|
||||
}
|
||||
);
|
||||
},
|
||||
callback
|
||||
);
|
||||
},
|
||||
|
@ -847,7 +913,13 @@ function trimMessageAreasScheduledEvent(args, cb) {
|
|||
//
|
||||
const maxExternalSentAgeDays = _.get(
|
||||
Config,
|
||||
'messageConferences.system_internal.areas.private_mail.maxExternalSentAgeDays',
|
||||
[
|
||||
'messageConferences',
|
||||
WellKnownConfTags.SystemInternal,
|
||||
'areas',
|
||||
WellKnownAreaTags.Private,
|
||||
'maxExternalSentAgeDays',
|
||||
],
|
||||
30
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
const WellKnownConfTags = {
|
||||
Invalid: '',
|
||||
SystemInternal: 'system_internal',
|
||||
ActivityPubInternal: 'activitypub_internal',
|
||||
};
|
||||
exports.WellKnownConfTags = WellKnownConfTags;
|
||||
|
||||
exports.SystemInternalConfTags = [WellKnownConfTags.SystemInternal];
|
||||
|
||||
const WellKnownAreaTags = {
|
||||
Invalid: '',
|
||||
Private: 'private_mail',
|
||||
Bulletin: 'local_bulletin',
|
||||
ActivityPubShared: 'activitypub_shared', // sharedInbox -> HERE -> exported as replies (direct) and outbox items (new posts)
|
||||
};
|
||||
exports.WellKnownAreaTags = WellKnownAreaTags;
|
||||
|
||||
const WellKnownExternalAreaTags = [WellKnownAreaTags.ActivityPubShared];
|
||||
exports.WellKnownExternalAreaTags = WellKnownExternalAreaTags;
|
||||
|
||||
const WellKnownMetaCategories = {
|
||||
System: 'System',
|
||||
FtnProperty: 'FtnProperty',
|
||||
FtnKludge: 'FtnKludge',
|
||||
QwkProperty: 'QwkProperty',
|
||||
QwkKludge: 'QwkKludge',
|
||||
ActivityPub: 'ActivityPub',
|
||||
};
|
||||
exports.WellKnownMetaCategories = WellKnownMetaCategories;
|
||||
|
||||
// Category: WellKnownMetaCategories.System ("System")
|
||||
const SystemMetaNames = {
|
||||
LocalToUserID: 'local_to_user_id',
|
||||
LocalFromUserID: 'local_from_user_id',
|
||||
StateFlags0: 'state_flags0', // See Message.StateFlags0
|
||||
ExplicitEncoding: 'explicit_encoding', // Explicitly set encoding when exporting/etc.
|
||||
ExternalFlavor: 'external_flavor', // "Flavor" of message - imported from or to be exported to. See Message.AddressFlavor
|
||||
RemoteToUser: 'remote_to_user', // Opaque value depends on external system, e.g. FTN address
|
||||
RemoteFromUser: 'remote_from_user', // Opaque value depends on external system, e.g. FTN address
|
||||
};
|
||||
exports.SystemMetaNames = SystemMetaNames;
|
||||
|
||||
// Types for Message.SystemMetaNames.ExternalFlavor meta
|
||||
const AddressFlavor = {
|
||||
Local: 'local', // local / non-remote addressing
|
||||
FTN: 'ftn', // FTN style
|
||||
Email: 'email', // From email
|
||||
QWK: 'qwk', // QWK packet
|
||||
NNTP: 'nntp', // NNTP article POST; often a email address
|
||||
ActivityPub: 'activitypub', // ActivityPub, Mastodon, etc.
|
||||
};
|
||||
exports.AddressFlavor = AddressFlavor;
|
||||
|
||||
const StateFlags0 = {
|
||||
None: 0x00000000,
|
||||
Imported: 0x00000001, // imported from foreign system
|
||||
Exported: 0x00000002, // exported to foreign system
|
||||
};
|
||||
exports.StateFlags0 = StateFlags0;
|
||||
|
||||
// Category: WellKnownMetaCategories.FtnProperty ("FtnProperty")
|
||||
const FtnPropertyNames = {
|
||||
// packet header oriented
|
||||
FtnOrigNode: 'ftn_orig_node',
|
||||
FtnDestNode: 'ftn_dest_node',
|
||||
// :TODO: rename these to ftn_*_net vs network - ensure things won't break, may need mapping
|
||||
FtnOrigNetwork: 'ftn_orig_network',
|
||||
FtnDestNetwork: 'ftn_dest_network',
|
||||
FtnAttrFlags: 'ftn_attr_flags',
|
||||
FtnCost: 'ftn_cost',
|
||||
FtnOrigZone: 'ftn_orig_zone',
|
||||
FtnDestZone: 'ftn_dest_zone',
|
||||
FtnOrigPoint: 'ftn_orig_point',
|
||||
FtnDestPoint: 'ftn_dest_point',
|
||||
|
||||
// message header oriented
|
||||
FtnMsgOrigNode: 'ftn_msg_orig_node',
|
||||
FtnMsgDestNode: 'ftn_msg_dest_node',
|
||||
FtnMsgOrigNet: 'ftn_msg_orig_net',
|
||||
FtnMsgDestNet: 'ftn_msg_dest_net',
|
||||
|
||||
FtnAttribute: 'ftn_attribute',
|
||||
|
||||
FtnTearLine: 'ftn_tear_line', // http://ftsc.org/docs/fts-0004.001
|
||||
FtnOrigin: 'ftn_origin', // http://ftsc.org/docs/fts-0004.001
|
||||
FtnArea: 'ftn_area', // http://ftsc.org/docs/fts-0004.001
|
||||
FtnSeenBy: 'ftn_seen_by', // http://ftsc.org/docs/fts-0004.001
|
||||
};
|
||||
exports.FtnPropertyNames = FtnPropertyNames;
|
||||
|
||||
// Category: WellKnownMetaCategories.QwkProperty
|
||||
const QWKPropertyNames = {
|
||||
MessageNumber: 'qwk_msg_num',
|
||||
MessageStatus: 'qwk_msg_status', // See http://wiki.synchro.net/ref:qwk for a decent list
|
||||
ConferenceNumber: 'qwk_conf_num',
|
||||
InReplyToNum: 'qwk_in_reply_to_num', // note that we prefer the 'InReplyToMsgId' kludge if available
|
||||
};
|
||||
exports.QWKPropertyNames = QWKPropertyNames;
|
||||
|
||||
// Category: WellKnownMetaCategories.ActivityPub
|
||||
const ActivityPubPropertyNames = {
|
||||
ActivityId: 'activitypub_activity_id', // Activity ID; FK to AP table entries
|
||||
InReplyTo: 'activitypub_in_reply_to', // Activity ID from 'inReplyTo' field
|
||||
NoteId: 'activitypub_note_id', // Note ID specific to Note Activities
|
||||
};
|
||||
exports.ActivityPubPropertyNames = ActivityPubPropertyNames;
|
|
@ -21,6 +21,14 @@ exports.loadModulesForCategory = loadModulesForCategory;
|
|||
exports.getModulePaths = getModulePaths;
|
||||
exports.initializeModules = initializeModules;
|
||||
|
||||
exports.moduleCategories = {
|
||||
Login: 'login',
|
||||
Content: 'content',
|
||||
Chat: 'chat',
|
||||
ScannerTossers: 'scannerTossers',
|
||||
WebHandlers: 'webHandlers',
|
||||
};
|
||||
|
||||
function loadModuleEx(options, cb) {
|
||||
assert(_.isObject(options));
|
||||
assert(_.isString(options.name));
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
'use strict';
|
||||
|
||||
// ENiGMA½
|
||||
const loadModulesForCategory = require('./module_util.js').loadModulesForCategory;
|
||||
const { loadModulesForCategory, moduleCategories } = require('./module_util');
|
||||
|
||||
// standard/deps
|
||||
const async = require('async');
|
||||
|
@ -18,7 +18,7 @@ function startup(cb) {
|
|||
[
|
||||
function loadModules(callback) {
|
||||
loadModulesForCategory(
|
||||
'scannerTossers',
|
||||
moduleCategories.ScannerTossers,
|
||||
(module, nextModule) => {
|
||||
const modInst = new module.getModule();
|
||||
|
||||
|
|
|
@ -113,6 +113,7 @@ function MultiLineEditTextView(options) {
|
|||
this.textLines = [];
|
||||
this.topVisibleIndex = 0;
|
||||
this.mode = options.mode || 'edit'; // edit | preview | read-only
|
||||
this.maxLength = 0; // no max by default
|
||||
|
||||
if ('preview' === this.mode) {
|
||||
this.autoScroll = options.autoScroll || true;
|
||||
|
@ -127,14 +128,6 @@ function MultiLineEditTextView(options) {
|
|||
//
|
||||
this.cursorPos = { col: 0, row: 0 };
|
||||
|
||||
this.getSGRFor = function (sgrFor) {
|
||||
return (
|
||||
{
|
||||
text: self.getSGR(),
|
||||
}[sgrFor] || self.getSGR()
|
||||
);
|
||||
};
|
||||
|
||||
this.isEditMode = function () {
|
||||
return 'edit' === self.mode;
|
||||
};
|
||||
|
@ -143,6 +136,10 @@ function MultiLineEditTextView(options) {
|
|||
return 'preview' === self.mode;
|
||||
};
|
||||
|
||||
this.getTextSgrPrefix = function () {
|
||||
return self.hasFocus ? self.getFocusSGR() : self.getSGR();
|
||||
};
|
||||
|
||||
// :TODO: Most of the calls to this could be avoided via incrementRow(), decrementRow() that keeps track or such
|
||||
this.getTextLinesIndex = function (row) {
|
||||
if (!_.isNumber(row)) {
|
||||
|
@ -170,7 +167,7 @@ function MultiLineEditTextView(options) {
|
|||
|
||||
this.toggleTextCursor = function (action) {
|
||||
self.client.term.rawWrite(
|
||||
`${self.getSGRFor('text')}${
|
||||
`${self.getTextSgrPrefix()}${
|
||||
'hide' === action ? ansi.hideCursor() : ansi.showCursor()
|
||||
}`
|
||||
);
|
||||
|
@ -182,11 +179,11 @@ function MultiLineEditTextView(options) {
|
|||
const startIndex = self.getTextLinesIndex(startRow);
|
||||
const endIndex = Math.min(self.getTextLinesIndex(endRow), self.textLines.length);
|
||||
const absPos = self.getAbsolutePosition(startRow, 0);
|
||||
const prefix = self.getTextSgrPrefix();
|
||||
|
||||
for (let i = startIndex; i < endIndex; ++i) {
|
||||
//${self.getSGRFor('text')}
|
||||
self.client.term.write(
|
||||
`${ansi.goto(absPos.row++, absPos.col)}${self.getRenderText(i)}`,
|
||||
`${ansi.goto(absPos.row++, absPos.col)}${prefix}${self.getRenderText(i)}`,
|
||||
false // convertLineFeeds
|
||||
);
|
||||
}
|
||||
|
@ -291,18 +288,20 @@ function MultiLineEditTextView(options) {
|
|||
|
||||
this.getOutputText = function (startIndex, endIndex, eolMarker, options) {
|
||||
const lines = self.getTextLines(startIndex, endIndex);
|
||||
let text = '';
|
||||
const re = new RegExp('\\t{1,' + self.tabWidth + '}', 'g');
|
||||
|
||||
lines.forEach(line => {
|
||||
text += line.text.replace(re, '\t');
|
||||
|
||||
if (options.forceLineTerms || (eolMarker && line.eol)) {
|
||||
return lines
|
||||
.map((line, lineIndex) => {
|
||||
let text = line.text.replace(re, '\t');
|
||||
if (
|
||||
options.forceLineTerms ||
|
||||
(eolMarker && line.eol && lineIndex < lines.length - 1)
|
||||
) {
|
||||
text += eolMarker;
|
||||
}
|
||||
});
|
||||
|
||||
return text;
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
|
||||
this.getContiguousText = function (startIndex, endIndex, includeEol) {
|
||||
|
@ -317,6 +316,15 @@ function MultiLineEditTextView(options) {
|
|||
return text;
|
||||
};
|
||||
|
||||
this.getCharacterLength = function () {
|
||||
// :TODO: FSE needs re-write anyway, but this should just be known all the time vs calc. Too much of a mess right now...
|
||||
let len = 0;
|
||||
this.textLines.forEach(tl => {
|
||||
len += tl.text.length;
|
||||
});
|
||||
return len;
|
||||
};
|
||||
|
||||
this.replaceCharacterInText = function (c, index, col) {
|
||||
self.textLines[index].text = strUtil.replaceAt(
|
||||
self.textLines[index].text,
|
||||
|
@ -482,7 +490,7 @@ function MultiLineEditTextView(options) {
|
|||
.slice(self.cursorPos.col - c.length);
|
||||
|
||||
self.client.term.write(
|
||||
`${ansi.hideCursor()}${self.getSGRFor('text')}${renderText}${ansi.goto(
|
||||
`${ansi.hideCursor()}${self.getTextSgrPrefix()}${renderText}${ansi.goto(
|
||||
absPos.row,
|
||||
absPos.col
|
||||
)}${ansi.showCursor()}`,
|
||||
|
@ -664,6 +672,10 @@ function MultiLineEditTextView(options) {
|
|||
};
|
||||
|
||||
this.keyPressCharacter = function (c) {
|
||||
if (this.maxLength > 0 && this.getCharacterLength() + 1 >= this.maxLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
var index = self.getTextLinesIndex();
|
||||
|
||||
//
|
||||
|
@ -1091,10 +1103,14 @@ MultiLineEditTextView.prototype.redraw = function () {
|
|||
};
|
||||
|
||||
MultiLineEditTextView.prototype.setFocus = function (focused) {
|
||||
this.client.term.rawWrite(this.getSGRFor('text'));
|
||||
this.moveClientCursorToCursorPos();
|
||||
|
||||
MultiLineEditTextView.super_.prototype.setFocus.call(this, focused);
|
||||
|
||||
if (this.isEditMode() && this.getSGR() !== this.getFocusSGR()) {
|
||||
this.redrawVisibleArea();
|
||||
} else {
|
||||
this.client.term.rawWrite(this.getTextSgrPrefix());
|
||||
}
|
||||
this.moveClientCursorToCursorPos();
|
||||
};
|
||||
|
||||
MultiLineEditTextView.prototype.setText = function (
|
||||
|
@ -1170,6 +1186,12 @@ MultiLineEditTextView.prototype.setPropertyValue = function (propName, value) {
|
|||
this.specialKeyMap.next = this.specialKeyMap.next || [];
|
||||
this.specialKeyMap.next.push('tab');
|
||||
break;
|
||||
|
||||
case 'maxLength':
|
||||
if (_.isNumber(value)) {
|
||||
this.maxLength = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
MultiLineEditTextView.super_.prototype.setPropertyValue.call(this, propName, value);
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
// ENiGMA½
|
||||
const { MenuModule, MenuFlags } = require('./menu_module');
|
||||
const Message = require('./message.js');
|
||||
const UserProps = require('./user_property.js');
|
||||
const { filterMessageListByReadACS } = require('./message_area.js');
|
||||
|
||||
exports.moduleInfo = {
|
||||
|
@ -46,7 +45,7 @@ exports.getModule = class MyMessagesModule extends MenuModule {
|
|||
finishedLoading() {
|
||||
if (!this.messageList || 0 === this.messageList.length) {
|
||||
return this.gotoMenu(
|
||||
this.menuConfig.config.noResultsMenu || 'messageSearchNoResults',
|
||||
this.menuConfig.config.noResultsMenu || 'messageSearchNoResults'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ const FileBaseFilters = require('./file_base_filter.js');
|
|||
const Errors = require('./enig_error.js').Errors;
|
||||
const { getAvailableFileAreaTags } = require('./file_base_area.js');
|
||||
const { valueAsArray } = require('./misc_util.js');
|
||||
const { SystemInternalConfTags } = require('./message_const');
|
||||
|
||||
// deps
|
||||
const _ = require('lodash');
|
||||
|
@ -80,12 +81,12 @@ exports.getModule = class NewScanModule extends MenuModule {
|
|||
);
|
||||
|
||||
//
|
||||
// Sort conferences by name, other than 'system_internal' which should
|
||||
// Sort conferences by name, other than "System Internal" which should
|
||||
// always come first such that we display private mails/etc. before
|
||||
// other conferences & areas
|
||||
//
|
||||
this.sortedMessageConfs.sort((a, b) => {
|
||||
if ('system_internal' === a.confTag) {
|
||||
if (SystemInternalConfTags.includes(a.confTag)) {
|
||||
return -1;
|
||||
} else {
|
||||
return a.conf.name.localeCompare(b.conf.name, {
|
||||
|
@ -156,8 +157,9 @@ exports.getModule = class NewScanModule extends MenuModule {
|
|||
},
|
||||
function getNewMessagesCountInArea(callback) {
|
||||
msgArea.getNewMessageCountInAreaForUser(
|
||||
self.client.user.userId,
|
||||
self.client.user,
|
||||
currentArea.areaTag,
|
||||
{ addrToOnly: false },
|
||||
(err, newMessageCount) => {
|
||||
callback(err, newMessageCount);
|
||||
}
|
||||
|
|
|
@ -50,10 +50,10 @@ exports.getModule = class NewUserAppModule extends MenuModule {
|
|||
|
||||
viewValidationListener: function (err, cb) {
|
||||
const errMsgView = self.viewControllers.menu.getView(MciViewIds.errMsg);
|
||||
let newFocusId;
|
||||
|
||||
let newFocusId;
|
||||
if (err) {
|
||||
errMsgView.setText(err.message);
|
||||
errMsgView.setText(err.friendlyText);
|
||||
err.view.clearText();
|
||||
|
||||
if (err.view.getId() === MciViewIds.confirm) {
|
||||
|
@ -66,7 +66,7 @@ exports.getModule = class NewUserAppModule extends MenuModule {
|
|||
errMsgView.clearText();
|
||||
}
|
||||
|
||||
return cb(newFocusId);
|
||||
return cb(err, newFocusId);
|
||||
},
|
||||
|
||||
//
|
||||
|
|
|
@ -0,0 +1,149 @@
|
|||
const {
|
||||
printUsageAndSetExitCode,
|
||||
ExitCodes,
|
||||
argv,
|
||||
initConfigAndDatabases,
|
||||
} = require('./oputil_common');
|
||||
const getHelpFor = require('./oputil_help.js').getHelpFor;
|
||||
const { Errors } = require('../enig_error');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
const { get } = require('lodash');
|
||||
|
||||
exports.handleUserCommand = handleUserCommand;
|
||||
|
||||
function applyAction(username, actionFunc, cb) {
|
||||
initConfigAndDatabases(err => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (!validateActivityPub()) {
|
||||
return cb(Errors.General('Activity Pub is not enabled'));
|
||||
}
|
||||
|
||||
if ('*' === username) {
|
||||
return actionFunc(null, cb);
|
||||
} else {
|
||||
const User = require('../../core/user.js');
|
||||
User.getUserIdAndName(username, (err, userId) => {
|
||||
if (err) {
|
||||
// try user ID if number was supplied
|
||||
userId = parseInt(userId);
|
||||
if (isNaN(userId)) {
|
||||
return cb(err);
|
||||
}
|
||||
}
|
||||
|
||||
User.getUser(userId, (err, user) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
return actionFunc(user, cb);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function conditionSingleUser(user, cb) {
|
||||
const { userNameToSubject, prepareLocalUserAsActor } = require('../activitypub/util');
|
||||
|
||||
const subject = userNameToSubject(user.username);
|
||||
if (!subject) {
|
||||
return cb(Errors.General(`Failed to get subject for ${user.username}`));
|
||||
}
|
||||
|
||||
console.info(`Conditioning ${user.username} (${user.userId}) -> ${subject}...`);
|
||||
prepareLocalUserAsActor(user, { force: argv.force }, err => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
user.persistProperties(user.properties, err => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function actionConditionAllUsers(_, cb) {
|
||||
const User = require('../../core/user.js');
|
||||
|
||||
User.getUserList({}, (err, userList) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
async.each(
|
||||
userList,
|
||||
(entry, next) => {
|
||||
User.getUser(entry.userId, (err, user) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
return conditionSingleUser(user, next);
|
||||
});
|
||||
},
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function validateActivityPub() {
|
||||
//
|
||||
// Web Server, and ActivityPub both must be enabled
|
||||
//
|
||||
const sysConfig = require('../config').get;
|
||||
const config = sysConfig();
|
||||
if (
|
||||
true !== get(config, 'contentServers.web.http.enabled') &&
|
||||
true !== get(config, 'contentServers.web.https.enabled')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true === get(config, 'contentServers.web.handlers.activityPub.enabled');
|
||||
}
|
||||
|
||||
function conditionUser(action, username) {
|
||||
return applyAction(
|
||||
username,
|
||||
'*' === username ? actionConditionAllUsers : conditionSingleUser,
|
||||
err => {
|
||||
if (err) {
|
||||
console.error(err.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleUserCommand() {
|
||||
const errUsage = () => {
|
||||
return printUsageAndSetExitCode(getHelpFor('ActivityPub'), ExitCodes.ERROR);
|
||||
};
|
||||
|
||||
if (true === argv.help) {
|
||||
return errUsage();
|
||||
}
|
||||
|
||||
const action = argv._[1];
|
||||
const usernameIdx = ['condition'].includes(action)
|
||||
? argv._.length - 1
|
||||
: argv._.length;
|
||||
const username = argv._[usernameIdx];
|
||||
|
||||
if (!username) {
|
||||
return errUsage();
|
||||
}
|
||||
|
||||
return (
|
||||
{
|
||||
condition: conditionUser,
|
||||
}[action] || errUsage
|
||||
)(action, username);
|
||||
}
|
|
@ -10,6 +10,7 @@ const async = require('async');
|
|||
const inq = require('inquirer');
|
||||
const fs = require('fs');
|
||||
const hjson = require('hjson');
|
||||
const log = require('../../core/logger');
|
||||
|
||||
const packageJson = require('../../package.json');
|
||||
|
||||
|
@ -81,6 +82,7 @@ function initConfigAndDatabases(cb) {
|
|||
initConfig(callback);
|
||||
},
|
||||
function initDb(callback) {
|
||||
log.init();
|
||||
db.initializeDatabases(callback);
|
||||
},
|
||||
function initArchiveUtil(callback) {
|
||||
|
|
|
@ -250,6 +250,7 @@ function buildNewConfig() {
|
|||
'new_user.in.hjson',
|
||||
'doors.in.hjson',
|
||||
'file_base.in.hjson',
|
||||
'activitypub.in.hjson',
|
||||
];
|
||||
|
||||
let includeFiles = [];
|
||||
|
|
|
@ -20,6 +20,7 @@ Commands:
|
|||
config Configuration management
|
||||
fb File base management
|
||||
mb Message base management
|
||||
ap ActivityPub management
|
||||
`,
|
||||
User: `usage: oputil.js user <action> [<arguments>]
|
||||
|
||||
|
@ -219,6 +220,15 @@ qwk-export arguments:
|
|||
TIMESTAMP.
|
||||
--no-qwke Disable QWKE extensions.
|
||||
--no-synchronet Disable Synchronet style extensions.
|
||||
`,
|
||||
ActivityPub: `usage: oputil.js ap <action> [<arguments>]
|
||||
Actions:
|
||||
condition USERNAME Condition user with system ActivityPub defaults
|
||||
|
||||
Instead of an actual USERNAME, the '*' character may be substituted.
|
||||
|
||||
condition arguments:
|
||||
--force Force condition; overrides any existing settings
|
||||
`,
|
||||
});
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ const handleFileBaseCommand = require('./oputil_file_base.js').handleFileBaseCom
|
|||
const handleMessageBaseCommand =
|
||||
require('./oputil_message_base.js').handleMessageBaseCommand;
|
||||
const handleConfigCommand = require('./oputil_config.js').handleConfigCommand;
|
||||
const handleApCommand = require('./activitypub').handleUserCommand;
|
||||
const getHelpFor = require('./oputil_help.js').getHelpFor;
|
||||
|
||||
module.exports = function () {
|
||||
|
@ -32,6 +33,8 @@ module.exports = function () {
|
|||
return handleFileBaseCommand();
|
||||
case 'mb':
|
||||
return handleMessageBaseCommand();
|
||||
case 'ap':
|
||||
return handleApCommand();
|
||||
default:
|
||||
return printUsageAndSetExitCode(getHelpFor('General'), ExitCodes.BAD_COMMAND);
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ const getHelpFor = require('./oputil_help.js').getHelpFor;
|
|||
const Errors = require('../enig_error.js').Errors;
|
||||
const UserProps = require('../user_property.js');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
const _ = require('lodash');
|
||||
const moment = require('moment');
|
||||
|
@ -337,7 +338,9 @@ function modUserGroups(user) {
|
|||
}
|
||||
|
||||
function showUserInfo(user) {
|
||||
const User = require('../../core/user.js');
|
||||
const User = require('../user');
|
||||
const ActivityPubSettings = require('../activitypub/settings');
|
||||
const { OTPTypes } = require('../user_2fa_otp');
|
||||
|
||||
const statusDesc = () => {
|
||||
const status = user.properties[UserProps.AccountStatus];
|
||||
|
@ -362,7 +365,9 @@ function showUserInfo(user) {
|
|||
return user.properties[UserProps.ThemeId];
|
||||
};
|
||||
|
||||
const stdInfo = `User information:
|
||||
const apSettings = ActivityPubSettings.fromUser(user);
|
||||
|
||||
let infoDump = `User information:
|
||||
Username : ${user.username}${user.isRoot() ? ' (root/SysOp)' : ''}
|
||||
Real name : ${propOrNA(UserProps.RealName)}
|
||||
ID : ${user.userId}
|
||||
|
@ -374,11 +379,19 @@ Last login : ${lastLogin()}
|
|||
Login count : ${propOrNA(UserProps.LoginCount)}
|
||||
Email : ${propOrNA(UserProps.EmailAddress)}
|
||||
Location : ${propOrNA(UserProps.Location)}
|
||||
Affiliations : ${propOrNA(UserProps.Affiliations)}`;
|
||||
let secInfo = '';
|
||||
if (argv.security) {
|
||||
Affiliations : ${propOrNA(UserProps.Affiliations)}
|
||||
ActivityPub : ${apSettings.enabled ? 'enabled' : 'disabled'}`;
|
||||
|
||||
const otp = user.getProperty(UserProps.AuthFactor2OTP);
|
||||
if (otp) {
|
||||
const oppDesc =
|
||||
{
|
||||
[OTPTypes.RFC6238_TOTP]: 'RFC6238 TOTP',
|
||||
[OTPTypes.RFC4266_HOTP]: 'rfc4266 HOTP',
|
||||
[OTPTypes.GoogleAuthenticator]: 'GoogleAuth',
|
||||
}[otp] || 'disabled';
|
||||
infoDump += `\n2FA OTP : ${oppDesc}`;
|
||||
|
||||
if (argv.security && otp) {
|
||||
const backupCodesOrNa = () => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
|
@ -388,13 +401,13 @@ Affiliations : ${propOrNA(UserProps.Affiliations)}`;
|
|||
return 'N/A';
|
||||
}
|
||||
};
|
||||
secInfo = `\n2FA OTP : ${otp}
|
||||
OTP secret : ${user.getProperty(UserProps.AuthFactor2OTPSecret) || 'N/A'}
|
||||
infoDump += `\nOTP secret : ${
|
||||
user.getProperty(UserProps.AuthFactor2OTPSecret) || 'N/A'
|
||||
}
|
||||
OTP Backup : ${backupCodesOrNa()}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`${stdInfo}${secInfo}`);
|
||||
console.info(infoDump);
|
||||
}
|
||||
|
||||
function twoFactorAuthOTP(user) {
|
||||
|
|
|
@ -13,6 +13,9 @@ const ANSI = require('./ansi_term.js');
|
|||
const UserProps = require('./user_property.js');
|
||||
const SysProps = require('./system_property.js');
|
||||
const SysLogKeys = require('./system_log.js');
|
||||
const ActivityPubSettings = require('./activitypub/settings');
|
||||
const { userNameToSubject } = require('./activitypub/util');
|
||||
const { getServer } = require('./listening_server');
|
||||
|
||||
// deps
|
||||
const packageJson = require('../package.json');
|
||||
|
@ -82,6 +85,15 @@ function userStatAsCountString(client, statName, defaultValue) {
|
|||
return toNumberWithCommas(value);
|
||||
}
|
||||
|
||||
// lazy cache
|
||||
let cachedWebServer;
|
||||
function getWebServer() {
|
||||
if (undefined === cachedWebServer) {
|
||||
cachedWebServer = getServer('codes.l33t.enigma.web.server');
|
||||
}
|
||||
return cachedWebServer ? cachedWebServer.instance : null;
|
||||
}
|
||||
|
||||
const PREDEFINED_MCI_GENERATORS = {
|
||||
//
|
||||
// Board
|
||||
|
@ -133,6 +145,17 @@ const PREDEFINED_MCI_GENERATORS = {
|
|||
UR: function realName(client) {
|
||||
return userStatAsString(client, UserProps.RealName, '');
|
||||
},
|
||||
AS: function activityPubSubjectName(client) {
|
||||
const activityPubSettings = ActivityPubSettings.fromUser(client.user);
|
||||
if (!activityPubSettings.enabled) {
|
||||
return '(disabled)';
|
||||
}
|
||||
const webServer = getWebServer();
|
||||
if (!webServer) {
|
||||
return 'N/A';
|
||||
}
|
||||
return userNameToSubject(client.user.username);
|
||||
},
|
||||
LO: function location(client) {
|
||||
return userStatAsString(client, UserProps.Location, '');
|
||||
},
|
||||
|
@ -288,10 +311,13 @@ const PREDEFINED_MCI_GENERATORS = {
|
|||
return StatLog.getUserStatNumByClient(
|
||||
client,
|
||||
UserProps.NewAddressedToMessageCount
|
||||
);
|
||||
).toString();
|
||||
},
|
||||
NP: function userNewPrivateMailCount(client) {
|
||||
return StatLog.getUserStatNumByClient(client, UserProps.NewPrivateMailCount);
|
||||
return StatLog.getUserStatNumByClient(
|
||||
client,
|
||||
UserProps.NewPrivateMailCount
|
||||
).toString();
|
||||
},
|
||||
IA: function userStatusAvailableIndicator(client) {
|
||||
const indicators = client.currentTheme.helpers.getStatusAvailIndicators();
|
||||
|
|
|
@ -1076,7 +1076,7 @@ class QWKPacketWriter extends EventEmitter {
|
|||
}
|
||||
|
||||
// First block is a space padded ID
|
||||
const id = `Created with ENiGMA 1/2 BBS v${enigmaVersion} Copyright (c) 2015-2022 Bryan Ashby`;
|
||||
const id = `Created with ENiGMA 1/2 BBS v${enigmaVersion} Copyright (c) 2015-2023 Bryan Ashby`;
|
||||
this.messagesStream.write(
|
||||
id.padEnd(QWKMessageBlockSize, ' '),
|
||||
'ascii'
|
||||
|
|
|
@ -0,0 +1,375 @@
|
|||
const Activity = require('../activitypub/activity');
|
||||
const Message = require('../message');
|
||||
const { MessageScanTossModule } = require('../msg_scan_toss_module');
|
||||
const { getServer } = require('../listening_server');
|
||||
const Log = require('../logger').log;
|
||||
const { WellKnownAreaTags, AddressFlavor } = require('../message_const');
|
||||
const { Errors } = require('../enig_error');
|
||||
const Collection = require('../activitypub/collection');
|
||||
const Note = require('../activitypub/note');
|
||||
const Endpoints = require('../activitypub/endpoint');
|
||||
const { getAddressedToInfo } = require('../mail_util');
|
||||
const { PublicCollectionId } = require('../activitypub/const');
|
||||
const Actor = require('../activitypub/actor');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
const _ = require('lodash');
|
||||
|
||||
exports.moduleInfo = {
|
||||
name: 'ActivityPub',
|
||||
desc: 'Provides ActivityPub scanner/tosser integration',
|
||||
author: 'NuSkooler',
|
||||
};
|
||||
|
||||
exports.getModule = class ActivityPubScannerTosser extends MessageScanTossModule {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.log = Log.child({ module: 'ActivityPubScannerTosser' });
|
||||
}
|
||||
|
||||
startup(cb) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
shutdown(cb) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
record(message) {
|
||||
if (!this._shouldExportMessage(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Private:
|
||||
// Send Note directly to another remote Actor's inbox
|
||||
//
|
||||
// Public:
|
||||
// - The original message may be addressed to a non-ActivityPub address
|
||||
// or something like "All" or "Public"; In this case, ignore that entry
|
||||
// - Additionally, we need to send to the local Actor's followers via their sharedInbox
|
||||
//
|
||||
// To achieve the above for Public, we'll collect the followers from the local
|
||||
// user, query their unique shared inboxes's, update the Note's addressing,
|
||||
// then deliver and store.
|
||||
//
|
||||
|
||||
async.waterfall(
|
||||
[
|
||||
callback => {
|
||||
Note.fromLocalMessage(message, this._webServer(), (err, noteInfo) => {
|
||||
return callback(err, noteInfo);
|
||||
});
|
||||
},
|
||||
(noteInfo, callback) => {
|
||||
if (message.isPrivate()) {
|
||||
if (!noteInfo.remoteActor) {
|
||||
return callback(
|
||||
Errors.UnexpectedState(
|
||||
'Private messages should contain a remote Actor!'
|
||||
)
|
||||
);
|
||||
}
|
||||
return callback(null, noteInfo, [noteInfo.remoteActor.inbox]);
|
||||
}
|
||||
|
||||
// public: we need to build a list of sharedInbox's
|
||||
this._collectDeliveryEndpoints(
|
||||
message,
|
||||
noteInfo.fromUser,
|
||||
(err, deliveryEndpoints) => {
|
||||
return callback(err, noteInfo, deliveryEndpoints);
|
||||
}
|
||||
);
|
||||
},
|
||||
(noteInfo, deliveryEndpoints, callback) => {
|
||||
const { note, fromUser, context } = noteInfo;
|
||||
|
||||
//
|
||||
// Update the Note's addressing:
|
||||
// - Private:
|
||||
// to: Directly to addressed-to Actor inbox
|
||||
//
|
||||
// - Public:
|
||||
// to: https://www.w3.org/ns/activitystreams#Public
|
||||
// ... and the message.getRemoteToUser() value *if*
|
||||
// the flavor is deemed ActivityPub
|
||||
// cc: [sharedInboxEndpoints]
|
||||
//
|
||||
if (message.isPrivate()) {
|
||||
note.to = deliveryEndpoints;
|
||||
} else {
|
||||
if (deliveryEndpoints.additionalTo) {
|
||||
note.to = [
|
||||
PublicCollectionId,
|
||||
deliveryEndpoints.additionalTo,
|
||||
];
|
||||
} else {
|
||||
note.to = PublicCollectionId;
|
||||
}
|
||||
note.cc = [
|
||||
deliveryEndpoints.followers,
|
||||
...deliveryEndpoints.sharedInboxes,
|
||||
];
|
||||
|
||||
if (note.to.length < 2 && note.cc.length < 2) {
|
||||
// If we only have a generic 'followers' endpoint, there is no where to send to
|
||||
return callback(null, activity, fromUser);
|
||||
}
|
||||
}
|
||||
|
||||
const activity = Activity.makeCreate(
|
||||
note.attributedTo,
|
||||
note,
|
||||
context
|
||||
);
|
||||
|
||||
let allEndpoints = Array.isArray(deliveryEndpoints)
|
||||
? deliveryEndpoints
|
||||
: deliveryEndpoints.sharedInboxes;
|
||||
if (deliveryEndpoints.additionalTo) {
|
||||
allEndpoints.push(deliveryEndpoints.additionalTo);
|
||||
}
|
||||
allEndpoints = Array.from(new Set(allEndpoints)); // unique again
|
||||
|
||||
async.eachLimit(
|
||||
allEndpoints,
|
||||
4,
|
||||
(inbox, nextInbox) => {
|
||||
activity.sendTo(inbox, fromUser, (err, respBody, res) => {
|
||||
if (err) {
|
||||
this.log.warn(
|
||||
{
|
||||
inbox,
|
||||
error: err.message,
|
||||
},
|
||||
'Failed to send "Note" Activity to Inbox'
|
||||
);
|
||||
} else if (
|
||||
res.statusCode === 200 ||
|
||||
res.statusCode === 202
|
||||
) {
|
||||
this.log.debug(
|
||||
{ inbox, uuid: message.uuid },
|
||||
'Message delivered to Inbox'
|
||||
);
|
||||
} else {
|
||||
this.log.warn(
|
||||
{
|
||||
inbox,
|
||||
statusCode: res.statusCode,
|
||||
body: _.truncate(respBody, 128),
|
||||
},
|
||||
'Unexpected status code'
|
||||
);
|
||||
}
|
||||
|
||||
// If we can't send now, no harm, we'll record to the outbox
|
||||
return nextInbox(null);
|
||||
});
|
||||
},
|
||||
() => {
|
||||
return callback(null, activity, fromUser, note);
|
||||
}
|
||||
);
|
||||
},
|
||||
(activity, fromUser, note, callback) => {
|
||||
Collection.addOutboxItem(
|
||||
fromUser,
|
||||
activity,
|
||||
message.isPrivate(),
|
||||
false, // do not ignore dupes
|
||||
(err, localId) => {
|
||||
if (!err) {
|
||||
this.log.debug(
|
||||
{ localId, activityId: activity.id, noteId: note.id },
|
||||
'Note Activity persisted to "outbox" collection"'
|
||||
);
|
||||
}
|
||||
return callback(err, activity);
|
||||
}
|
||||
);
|
||||
},
|
||||
(activity, callback) => {
|
||||
// mark exported
|
||||
return message.persistMetaValue(
|
||||
Message.WellKnownMetaCategories.System,
|
||||
Message.SystemMetaNames.StateFlags0,
|
||||
Message.StateFlags0.Exported.toString(),
|
||||
err => {
|
||||
return callback(err, activity);
|
||||
}
|
||||
);
|
||||
},
|
||||
(activity, callback) => {
|
||||
// message -> Activity ID relation
|
||||
return message.persistMetaValue(
|
||||
Message.WellKnownMetaCategories.ActivityPub,
|
||||
Message.ActivityPubPropertyNames.ActivityId,
|
||||
activity.id,
|
||||
err => {
|
||||
return callback(err, activity);
|
||||
}
|
||||
);
|
||||
},
|
||||
(activity, callback) => {
|
||||
return message.persistMetaValue(
|
||||
Message.WellKnownMetaCategories.ActivityPub,
|
||||
Message.ActivityPubPropertyNames.NoteId,
|
||||
activity.object.id,
|
||||
err => {
|
||||
return callback(err, activity);
|
||||
}
|
||||
);
|
||||
},
|
||||
],
|
||||
(err, activity) => {
|
||||
// dupes aren't considered failure
|
||||
if (err) {
|
||||
if (err.code === 'SQLITE_CONSTRAINT') {
|
||||
this.log.debug({ id: activity.id }, 'Ignoring duplicate');
|
||||
} else {
|
||||
this.log.error(
|
||||
{ error: err.message, messageId: message.messageId },
|
||||
'Failed to export message to ActivityPub'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.log.info(
|
||||
{ activityId: activity.id, noteId: activity.object.id },
|
||||
'Note Activity published successfully'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_collectDeliveryEndpoints(message, localUser, cb) {
|
||||
this._collectFollowersSharedInboxEndpoints(
|
||||
localUser,
|
||||
(err, endpoints, followersEndpoint) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
//
|
||||
// Don't inspect the remote address/remote to
|
||||
// Here; We already know this in a public
|
||||
// area. Instead, see if the user typed in
|
||||
// a reasonable AP address here. If so, we'll
|
||||
// try to send directly to them as well.
|
||||
//
|
||||
const addrInfo = getAddressedToInfo(message.toUserName);
|
||||
if (
|
||||
!message.isPrivate() &&
|
||||
AddressFlavor.ActivityPub === addrInfo.flavor
|
||||
) {
|
||||
Actor.fromId(addrInfo.remote, (err, actor) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
return cb(null, {
|
||||
additionalTo: actor.inbox,
|
||||
sharedInboxes: endpoints,
|
||||
followers: followersEndpoint,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return cb(null, {
|
||||
sharedInboxes: endpoints,
|
||||
followers: followersEndpoint,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_collectFollowersSharedInboxEndpoints(localUser, cb) {
|
||||
const localFollowersEndpoint = Endpoints.followers(localUser);
|
||||
|
||||
Collection.followers(localFollowersEndpoint, 'all', (err, collection) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
if (!collection.orderedItems || collection.orderedItems.length < 1) {
|
||||
// no followers :(
|
||||
return cb(null, []);
|
||||
}
|
||||
|
||||
async.mapLimit(
|
||||
collection.orderedItems,
|
||||
4,
|
||||
(actorId, nextActorId) => {
|
||||
Actor.fromId(actorId, (err, actor) => {
|
||||
return nextActorId(err, actor);
|
||||
});
|
||||
},
|
||||
(err, followerActors) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
const sharedInboxEndpoints = Array.from(
|
||||
new Set(
|
||||
followerActors
|
||||
.map(actor => {
|
||||
return _.get(actor, 'endpoints.sharedInbox');
|
||||
})
|
||||
.filter(inbox => inbox) // drop nulls
|
||||
)
|
||||
);
|
||||
|
||||
return cb(null, sharedInboxEndpoints, localFollowersEndpoint);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_isEnabled() {
|
||||
// :TODO: check config to see if AP integration is enabled/etc.
|
||||
return this._webServer();
|
||||
}
|
||||
|
||||
_shouldExportMessage(message) {
|
||||
//
|
||||
// - Private messages: Must be ActivityPub flavor
|
||||
// - Public messages: Must be in area mapped for ActivityPub import/export
|
||||
//
|
||||
if (
|
||||
Message.AddressFlavor.ActivityPub === message.getAddressFlavor() &&
|
||||
message.isPrivate()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Public items do not need a specific 'to'; we'll record to the
|
||||
// local Actor's outbox and send to any followers we know about
|
||||
if (message.areaTag === WellKnownAreaTags.ActivityPubShared) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// :TODO: Implement the area mapping check for public 'groups'
|
||||
return false;
|
||||
}
|
||||
|
||||
_exportToActivityPub(message, cb) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
_webServer() {
|
||||
// we have to lazy init
|
||||
if (undefined === this.webServer) {
|
||||
this.webServer = getServer('codes.l33t.enigma.web.server') || null;
|
||||
}
|
||||
|
||||
return this.webServer ? this.webServer.instance : null;
|
||||
}
|
||||
};
|
|
@ -1622,6 +1622,9 @@ function FTNMessageScanTossModule() {
|
|||
const addrString = new Address(
|
||||
packetHeader.destAddress
|
||||
).toString();
|
||||
|
||||
importStats.otherFail += 1;
|
||||
|
||||
return next(
|
||||
new Error(
|
||||
`No local configuration for packet addressed to ${addrString}`
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
/* jslint node: true */
|
||||
'use strict';
|
||||
|
||||
// ENiGMA½
|
||||
const Log = require('../../logger.js').log;
|
||||
const SysLogger = require('../../logger.js').log;
|
||||
const ServerModule = require('../../server_module.js').ServerModule;
|
||||
const Config = require('../../config.js').get;
|
||||
const { Errors } = require('../../enig_error.js');
|
||||
const { loadModulesForCategory, moduleCategories } = require('../../module_util');
|
||||
const WebHandlerModule = require('../../web_handler_module');
|
||||
|
||||
// deps
|
||||
const http = require('http');
|
||||
|
@ -16,6 +15,7 @@ const paths = require('path');
|
|||
const mimeTypes = require('mime-types');
|
||||
const forEachSeries = require('async/forEachSeries');
|
||||
const findSeries = require('async/findSeries');
|
||||
const WebLog = require('../../web_log.js');
|
||||
|
||||
const ModuleInfo = (exports.moduleInfo = {
|
||||
name: 'Web',
|
||||
|
@ -24,6 +24,11 @@ const ModuleInfo = (exports.moduleInfo = {
|
|||
packageName: 'codes.l33t.enigma.web.server',
|
||||
});
|
||||
|
||||
exports.WellKnownLocations = {
|
||||
Rfc5785: '/.well-known', // https://www.rfc-editor.org/rfc/rfc5785
|
||||
Internal: '/_enig', // location of most enigma provided routes
|
||||
};
|
||||
|
||||
class Route {
|
||||
constructor(route) {
|
||||
Object.assign(this, route);
|
||||
|
@ -35,7 +40,7 @@ class Route {
|
|||
try {
|
||||
this.pathRegExp = new RegExp(this.path);
|
||||
} catch (e) {
|
||||
Log.debug({ route: route }, 'Invalid regular expression for route path');
|
||||
this.log.error({ route: route }, 'Invalid regular expression for route path');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,6 +75,8 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
constructor() {
|
||||
super();
|
||||
|
||||
this.log = WebLog.createWebLog();
|
||||
|
||||
const config = Config();
|
||||
this.enableHttp = config.contentServers.web.http.enabled || false;
|
||||
this.enableHttps = config.contentServers.web.https.enabled || false;
|
||||
|
@ -77,36 +84,8 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
this.routes = {};
|
||||
}
|
||||
|
||||
buildUrl(pathAndQuery) {
|
||||
//
|
||||
// Create a URL such as
|
||||
// https://l33t.codes:44512/ + |pathAndQuery|
|
||||
//
|
||||
// Prefer HTTPS over HTTP. Be explicit about the port
|
||||
// only if non-standard. Allow users to override full prefix in config.
|
||||
//
|
||||
const config = Config();
|
||||
if (_.isString(config.contentServers.web.overrideUrlPrefix)) {
|
||||
return `${config.contentServers.web.overrideUrlPrefix}${pathAndQuery}`;
|
||||
}
|
||||
|
||||
let schema;
|
||||
let port;
|
||||
if (config.contentServers.web.https.enabled) {
|
||||
schema = 'https://';
|
||||
port =
|
||||
443 === config.contentServers.web.https.port
|
||||
? ''
|
||||
: `:${config.contentServers.web.https.port}`;
|
||||
} else {
|
||||
schema = 'http://';
|
||||
port =
|
||||
80 === config.contentServers.web.http.port
|
||||
? ''
|
||||
: `:${config.contentServers.web.http.port}`;
|
||||
}
|
||||
|
||||
return `${schema}${config.contentServers.web.domain}${port}${pathAndQuery}`;
|
||||
logger() {
|
||||
return this.log;
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
|
@ -115,9 +94,12 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
|
||||
createServer(cb) {
|
||||
if (this.enableHttp) {
|
||||
this.httpServer = http.createServer((req, resp) =>
|
||||
this.routeRequest(req, resp)
|
||||
);
|
||||
this.httpServer = http.createServer((req, resp) => {
|
||||
resp.on('error', err => {
|
||||
this.log.error({ error: err.message }, 'Response error');
|
||||
});
|
||||
this.routeRequest(req, resp);
|
||||
});
|
||||
}
|
||||
|
||||
const config = Config();
|
||||
|
@ -138,6 +120,47 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
return cb(null);
|
||||
}
|
||||
|
||||
beforeListen(cb) {
|
||||
if (!this.isEnabled()) {
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
loadModulesForCategory(
|
||||
moduleCategories.WebHandlers,
|
||||
(module, nextModule) => {
|
||||
const moduleInst = new module.getModule();
|
||||
try {
|
||||
const normalizedName = _.camelCase(module.moduleInfo.name);
|
||||
if (!WebHandlerModule.isEnabled(normalizedName)) {
|
||||
SysLogger.info(
|
||||
{ moduleName: normalizedName },
|
||||
'Web handler module not enabled'
|
||||
);
|
||||
return nextModule(null);
|
||||
}
|
||||
|
||||
SysLogger.info(
|
||||
{ moduleName: normalizedName },
|
||||
'Initializing web handler module'
|
||||
);
|
||||
|
||||
moduleInst.init(this, err => {
|
||||
return nextModule(err);
|
||||
});
|
||||
} catch (e) {
|
||||
SysLogger.error(
|
||||
{ error: e.message },
|
||||
'Exception caught loading web handler'
|
||||
);
|
||||
return nextModule(e);
|
||||
}
|
||||
},
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
listen(cb) {
|
||||
const config = Config();
|
||||
forEachSeries(
|
||||
|
@ -147,12 +170,12 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
if (this[name]) {
|
||||
const port = parseInt(config.contentServers.web[service].port);
|
||||
if (isNaN(port)) {
|
||||
Log.warn(
|
||||
SysLogger.error(
|
||||
{
|
||||
port: config.contentServers.web[service].port,
|
||||
server: ModuleInfo.name,
|
||||
},
|
||||
`Invalid port (${service})`
|
||||
`Invalid web port (${service})`
|
||||
);
|
||||
return nextService(
|
||||
Errors.Invalid(
|
||||
|
@ -182,16 +205,13 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
route = new Route(route);
|
||||
|
||||
if (!route.isValid()) {
|
||||
Log.warn(
|
||||
{ route: route },
|
||||
'Cannot add route: missing or invalid required members'
|
||||
);
|
||||
SysLogger.error({ route: route }, 'Cannot add invalid route');
|
||||
return false;
|
||||
}
|
||||
|
||||
const routeKey = route.getRouteKey();
|
||||
if (routeKey in this.routes) {
|
||||
Log.warn(
|
||||
SysLogger.warn(
|
||||
{ route: route, routeKey: routeKey },
|
||||
'Cannot add route: duplicate method/path combination exists'
|
||||
);
|
||||
|
@ -203,6 +223,8 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
}
|
||||
|
||||
routeRequest(req, resp) {
|
||||
this.log.trace({ req }, 'Request');
|
||||
|
||||
let route = _.find(this.routes, r => r.matchesRequest(req));
|
||||
|
||||
if (route) {
|
||||
|
@ -249,6 +271,28 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
});
|
||||
}
|
||||
|
||||
ok(resp, body = '', headers = { 'Content-Type': 'text/html' }) {
|
||||
if (body && !headers['Content-Length']) {
|
||||
headers['Content-Length'] = Buffer.from(body).length;
|
||||
}
|
||||
resp.writeHead(200, 'OK', body ? headers : null);
|
||||
return resp.end(body);
|
||||
}
|
||||
|
||||
created(resp, body = '', headers = { 'Content-Type': 'text/html' }) {
|
||||
resp.writeHead(201, 'Created', body ? headers : null);
|
||||
return resp.end(body);
|
||||
}
|
||||
|
||||
accepted(resp, body = '', headers = { 'Content-Type': 'text/html' }) {
|
||||
resp.writeHead(202, 'Accepted', body ? headers : null);
|
||||
return resp.end(body);
|
||||
}
|
||||
|
||||
badRequest(resp) {
|
||||
return this.respondWithError(resp, 400, 'Bad request.', 'Bad Request');
|
||||
}
|
||||
|
||||
accessDenied(resp) {
|
||||
return this.respondWithError(resp, 401, 'Access denied.', 'Access Denied');
|
||||
}
|
||||
|
@ -257,6 +301,31 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
return this.respondWithError(resp, 404, 'File not found.', 'File Not Found');
|
||||
}
|
||||
|
||||
resourceNotFound(resp) {
|
||||
return this.respondWithError(
|
||||
resp,
|
||||
404,
|
||||
'Resource not found.',
|
||||
'Resource Not Found'
|
||||
);
|
||||
}
|
||||
|
||||
internalServerError(resp, err) {
|
||||
if (err) {
|
||||
this.log.error({ error: err.message }, 'Internal server error');
|
||||
}
|
||||
return this.respondWithError(
|
||||
resp,
|
||||
500,
|
||||
'Internal server error.',
|
||||
'Internal Server Error'
|
||||
);
|
||||
}
|
||||
|
||||
notImplemented(resp) {
|
||||
return this.respondWithError(resp, 501, 'Not implemented.', 'Not Implemented');
|
||||
}
|
||||
|
||||
tryRouteIndex(req, resp, cb) {
|
||||
const tryFiles = Config().contentServers.web.tryFiles || [
|
||||
'index.html',
|
||||
|
@ -270,8 +339,8 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
req.url.substr(req.url.lastIndexOf('/', 1)),
|
||||
tryFile
|
||||
);
|
||||
const filePath = this.resolveStaticPath(fileName);
|
||||
|
||||
const filePath = this.resolveStaticPath(fileName);
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
if (err || !stats.isFile()) {
|
||||
return nextTryFile(null, false);
|
||||
|
@ -333,6 +402,18 @@ exports.getModule = class WebServerModule extends ServerModule {
|
|||
}
|
||||
}
|
||||
|
||||
resolveTemplatePath(path) {
|
||||
if (paths.isAbsolute(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const staticRoot = _.get(Config(), 'contentServers.web.staticRoot');
|
||||
const resolved = paths.resolve(staticRoot, path);
|
||||
if (resolved.startsWith(staticRoot)) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
routeTemplateFilePage(templatePath, preprocessCallback, resp) {
|
||||
const self = this;
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,126 @@
|
|||
const WebHandlerModule = require('../../../web_handler_module');
|
||||
const { Errors } = require('../../../enig_error');
|
||||
const EngiAssert = require('../../../enigma_assert');
|
||||
const Config = require('../../../config').get;
|
||||
const packageJson = require('../../../../package.json');
|
||||
const StatLog = require('../../../stat_log');
|
||||
const SysProps = require('../../../system_property');
|
||||
const SysLogKeys = require('../../../system_log');
|
||||
const { getBaseUrl, getWebDomain } = require('../../../web_util');
|
||||
|
||||
// deps
|
||||
const moment = require('moment');
|
||||
const async = require('async');
|
||||
|
||||
exports.moduleInfo = {
|
||||
name: 'NodeInfo2',
|
||||
desc: 'A NodeInfo2 Handler implementing https://github.com/jaywink/nodeinfo2',
|
||||
author: 'NuSkooler',
|
||||
packageName: 'codes.l33t.enigma.web.handler.nodeinfo2',
|
||||
};
|
||||
|
||||
exports.getModule = class NodeInfo2WebHandler extends WebHandlerModule {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
init(webServer, cb) {
|
||||
// we rely on the web server
|
||||
this.webServer = webServer;
|
||||
EngiAssert(webServer, 'NodeInfo2 Web Handler init without webServer');
|
||||
|
||||
this.log = webServer.logger().child({ webHandler: 'NodeInfo2' });
|
||||
|
||||
const domain = getWebDomain();
|
||||
if (!domain) {
|
||||
return cb(Errors.UnexpectedState('Web server does not have "domain" set'));
|
||||
}
|
||||
|
||||
this.webServer.addRoute({
|
||||
method: 'GET',
|
||||
path: /^\/\.well-known\/x-nodeinfo2$/,
|
||||
handler: this._nodeInfo2Handler.bind(this),
|
||||
});
|
||||
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
_nodeInfo2Handler(req, resp) {
|
||||
this.log.info('Serving NodeInfo2 request');
|
||||
|
||||
this._getNodeInfo(nodeInfo => {
|
||||
const body = JSON.stringify(nodeInfo);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.from(body).length,
|
||||
};
|
||||
|
||||
resp.writeHead(200, headers);
|
||||
return resp.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
_getNodeInfo(cb) {
|
||||
// https://github.com/jaywink/nodeinfo2/tree/master/schemas/1.0
|
||||
const config = Config();
|
||||
const nodeInfo = {
|
||||
version: '1.0',
|
||||
server: {
|
||||
baseUrl: getBaseUrl(),
|
||||
name: config.general.boardName,
|
||||
software: 'ENiGMA½ Bulletin Board Software',
|
||||
version: packageJson.version,
|
||||
},
|
||||
// :TODO: Only list what's enabled
|
||||
protocols: ['telnet', 'ssh', 'gopher', 'nntp', 'ws', 'activitypub'],
|
||||
|
||||
// :TODO: what should we really be doing here???
|
||||
// services: {
|
||||
// inbound: [],
|
||||
// outbound: [],
|
||||
// },
|
||||
openRegistrations: !config.general.closedSystem,
|
||||
usage: {
|
||||
users: {
|
||||
total: StatLog.getSystemStatNum(SysProps.TotalUserCount) || 1,
|
||||
// others fetched dynamically below
|
||||
},
|
||||
|
||||
// :TODO: pop with local message
|
||||
// select count() from message_meta where meta_name='local_from_user_id';
|
||||
localPosts: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const setActive = (since, name, next) => {
|
||||
const filter = {
|
||||
logName: SysLogKeys.UserLoginHistory,
|
||||
resultType: 'count',
|
||||
dateNewer: moment().subtract(moment.duration(since, 'days')),
|
||||
};
|
||||
StatLog.findSystemLogEntries(filter, (err, count) => {
|
||||
if (!err) {
|
||||
nodeInfo.usage[name] = count;
|
||||
}
|
||||
return next(null);
|
||||
});
|
||||
};
|
||||
|
||||
async.series(
|
||||
[
|
||||
callback => {
|
||||
return setActive(180, 'activeHalfyear', callback);
|
||||
},
|
||||
callback => {
|
||||
return setActive(30, 'activeMonth', callback);
|
||||
},
|
||||
callback => {
|
||||
return setActive(7, 'activeWeek', callback);
|
||||
},
|
||||
],
|
||||
() => {
|
||||
return cb(nodeInfo);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,75 @@
|
|||
const WebHandlerModule = require('../../../web_handler_module');
|
||||
const { Errors } = require('../../../enig_error');
|
||||
const EngiAssert = require('../../../enigma_assert');
|
||||
const Config = require('../../../config').get;
|
||||
const { getFullUrl, getWebDomain } = require('../../../web_util');
|
||||
|
||||
// deps
|
||||
const paths = require('path');
|
||||
const fs = require('fs');
|
||||
const mimeTypes = require('mime-types');
|
||||
const get = require('lodash/get');
|
||||
|
||||
exports.moduleInfo = {
|
||||
name: 'SystemGeneral',
|
||||
desc: 'A general handler for system routes',
|
||||
author: 'NuSkooler',
|
||||
packageName: 'codes.l33t.enigma.web.handler.general_system',
|
||||
};
|
||||
|
||||
exports.getModule = class SystemGeneralWebHandler extends WebHandlerModule {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
init(webServer, cb) {
|
||||
// we rely on the web server
|
||||
this.webServer = webServer;
|
||||
EngiAssert(webServer, 'System General Web Handler init without webServer');
|
||||
|
||||
const domain = getWebDomain();
|
||||
if (!domain) {
|
||||
return cb(Errors.UnexpectedState('Web server does not have "domain" set'));
|
||||
}
|
||||
|
||||
// default avatar routing
|
||||
this.webServer.addRoute({
|
||||
method: 'GET',
|
||||
path: /^\/_enig\/users\/.+\/avatar\/.+\.(png|jpg|jpeg|gif|webp)$/,
|
||||
handler: this._avatarGetHandler.bind(this),
|
||||
});
|
||||
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
_avatarGetHandler(req, resp) {
|
||||
const url = getFullUrl(req);
|
||||
const filename = paths.basename(url.pathname);
|
||||
if (!filename) {
|
||||
return this.webServer.fileNotFound(resp);
|
||||
}
|
||||
|
||||
const storagePath = get(Config(), 'users.avatars.storagePath');
|
||||
if (!storagePath) {
|
||||
return this.webServer.fileNotFound(resp);
|
||||
}
|
||||
|
||||
const localPath = paths.join(storagePath, filename);
|
||||
fs.stat(localPath, (err, stats) => {
|
||||
if (err || !stats.isFile()) {
|
||||
return this.webServer.accessDenied(resp);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type':
|
||||
mimeTypes.contentType(paths.basename(localPath)) ||
|
||||
mimeTypes.contentType('.png'),
|
||||
'Content-Length': stats.size,
|
||||
};
|
||||
|
||||
const readStream = fs.createReadStream(localPath);
|
||||
resp.writeHead(200, headers);
|
||||
readStream.pipe(resp);
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,255 @@
|
|||
const WebHandlerModule = require('../../../web_handler_module');
|
||||
const Config = require('../../../config').get;
|
||||
const { Errors, ErrorReasons } = require('../../../enig_error');
|
||||
const { WellKnownLocations } = require('../web');
|
||||
const {
|
||||
getUserProfileTemplatedBody,
|
||||
DefaultProfileTemplate,
|
||||
} = require('../../../activitypub/util');
|
||||
const Endpoints = require('../../../activitypub/endpoint');
|
||||
const EngiAssert = require('../../../enigma_assert');
|
||||
const User = require('../../../user');
|
||||
const UserProps = require('../../../user_property');
|
||||
const ActivityPubSettings = require('../../../activitypub/settings');
|
||||
const { getFullUrl, buildUrl, getWebDomain } = require('../../../web_util');
|
||||
|
||||
// deps
|
||||
const _ = require('lodash');
|
||||
const Actor = require('../../../activitypub/actor');
|
||||
|
||||
exports.moduleInfo = {
|
||||
name: 'WebFinger',
|
||||
desc: 'A simple WebFinger Handler.',
|
||||
author: 'NuSkooler, CognitiveGears',
|
||||
packageName: 'codes.l33t.enigma.web.handler.webfinger',
|
||||
};
|
||||
|
||||
//
|
||||
// WebFinger: https://www.rfc-editor.org/rfc/rfc7033
|
||||
//
|
||||
exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
init(webServer, cb) {
|
||||
// we rely on the web server
|
||||
this.webServer = webServer;
|
||||
EngiAssert(webServer, 'WebFinger Web Handler init without webServer');
|
||||
|
||||
this.log = webServer.logger().child({ webHandler: 'WebFinger' });
|
||||
|
||||
const domain = getWebDomain();
|
||||
if (!domain) {
|
||||
return cb(Errors.UnexpectedState('Web server does not have "domain" set'));
|
||||
}
|
||||
|
||||
this.acceptedResourceRegExps = [
|
||||
// acct:NAME@our.domain.tld
|
||||
// https://www.rfc-editor.org/rfc/rfc7565
|
||||
new RegExp(`^acct:(.+)@${domain}$`),
|
||||
// profile page
|
||||
// https://webfinger.net/rel/profile-page/
|
||||
new RegExp(`^${buildUrl(WellKnownLocations.Internal + '/wf/@')}(.+)$`),
|
||||
// self URL
|
||||
new RegExp(`^${buildUrl(WellKnownLocations.Internal + '/ap/users/')}(.+)$`),
|
||||
];
|
||||
|
||||
this.webServer.addRoute({
|
||||
method: 'GET',
|
||||
// https://www.rfc-editor.org/rfc/rfc7033.html#section-10.1
|
||||
path: /^\/\.well-known\/webfinger\/?\?/,
|
||||
handler: this._webFingerRequestHandler.bind(this),
|
||||
});
|
||||
|
||||
this.webServer.addRoute({
|
||||
method: 'GET',
|
||||
path: /^\/_enig\/wf\/@.+$/,
|
||||
handler: this._profileRequestHandler.bind(this),
|
||||
});
|
||||
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
_profileRequestHandler(req, resp) {
|
||||
// Profile requests do not have an Actor ID available
|
||||
const profileQuery = getFullUrl(req).toString();
|
||||
const accountName = this._getAccountName(profileQuery);
|
||||
if (!accountName) {
|
||||
this.log.warn(
|
||||
`Failed to parse "account name" for profile query: ${profileQuery}`
|
||||
);
|
||||
return this.webServer.resourceNotFound(resp);
|
||||
}
|
||||
|
||||
this._localUserFromWebFingerAccountName(accountName, (err, localUser) => {
|
||||
if (err) {
|
||||
this.log.warn(
|
||||
{ error: err.message, type: 'Profile', accountName },
|
||||
'Could not fetch profile for WebFinger request'
|
||||
);
|
||||
return this.webServer.resourceNotFound(resp);
|
||||
}
|
||||
|
||||
let templateFile = _.get(
|
||||
Config(),
|
||||
'contentServers.web.handlers.webFinger.profileTemplate'
|
||||
);
|
||||
if (templateFile) {
|
||||
templateFile = this.webServer.resolveTemplatePath(templateFile);
|
||||
}
|
||||
|
||||
Actor.fromLocalUser(localUser, (err, localActor) => {
|
||||
if (err) {
|
||||
return this.webServer.internalServerError(resp, err);
|
||||
}
|
||||
|
||||
getUserProfileTemplatedBody(
|
||||
templateFile,
|
||||
localUser,
|
||||
localActor,
|
||||
DefaultProfileTemplate,
|
||||
'text/plain',
|
||||
(err, body, contentType) => {
|
||||
if (err) {
|
||||
return this.webServer.resourceNotFound(resp);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': Buffer.from(body).length,
|
||||
};
|
||||
|
||||
resp.writeHead(200, headers);
|
||||
return resp.end(body);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_webFingerRequestHandler(req, resp) {
|
||||
const url = getFullUrl(req);
|
||||
const resource = url.searchParams.get('resource');
|
||||
if (!resource) {
|
||||
return this.webServer.respondWithError(
|
||||
resp,
|
||||
400,
|
||||
'"resource" is required',
|
||||
'Missing "resource"'
|
||||
);
|
||||
}
|
||||
|
||||
const accountName = this._getAccountName(resource);
|
||||
if (!accountName || accountName.length < 1) {
|
||||
this.log.warn(`Failed to parse "account name" for resource: ${resource}`);
|
||||
return this.webServer.resourceNotFound(resp);
|
||||
}
|
||||
|
||||
this._localUserFromWebFingerAccountName(accountName, (err, localUser) => {
|
||||
if (err) {
|
||||
this.log.warn(
|
||||
{ url: req.url, error: err.message, type: 'WebFinger' },
|
||||
`No account for "${accountName}" could be retrieved`
|
||||
);
|
||||
return this.webServer.resourceNotFound(resp);
|
||||
}
|
||||
|
||||
const domain = getWebDomain();
|
||||
const body = JSON.stringify({
|
||||
subject: `acct:${localUser.username}@${domain}`,
|
||||
aliases: [Endpoints.profile(localUser), Endpoints.actorId(localUser)],
|
||||
links: [
|
||||
this._profilePageLink(localUser),
|
||||
this._selfLink(localUser),
|
||||
this._subscribeLink(),
|
||||
],
|
||||
});
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/jrd+json',
|
||||
'Content-Length': Buffer.from(body).length,
|
||||
};
|
||||
|
||||
resp.writeHead(200, headers);
|
||||
return resp.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
_localUserFromWebFingerAccountName(accountName, cb) {
|
||||
if (accountName.startsWith('@')) {
|
||||
accountName = accountName.slice(1);
|
||||
}
|
||||
|
||||
User.getUserIdAndName(accountName, (err, userId) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
User.getUser(userId, (err, user) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
const accountStatus = user.getPropertyAsNumber(UserProps.AccountStatus);
|
||||
if (
|
||||
User.AccountStatus.disabled == accountStatus ||
|
||||
User.AccountStatus.inactive == accountStatus
|
||||
) {
|
||||
return cb(
|
||||
Errors.AccessDenied('Account disabled', ErrorReasons.Disabled)
|
||||
);
|
||||
}
|
||||
|
||||
const activityPubSettings = ActivityPubSettings.fromUser(user);
|
||||
if (!activityPubSettings.enabled) {
|
||||
return cb(Errors.AccessDenied('ActivityPub is not enabled for user'));
|
||||
}
|
||||
|
||||
return cb(null, user);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_profilePageLink(user) {
|
||||
const href = Endpoints.profile(user);
|
||||
return {
|
||||
rel: 'http://webfinger.net/rel/profile-page',
|
||||
type: 'text/plain',
|
||||
href,
|
||||
};
|
||||
}
|
||||
|
||||
_userActorId(user) {
|
||||
return Endpoints.actorId(user);
|
||||
}
|
||||
|
||||
// :TODO: only if ActivityPub is enabled
|
||||
_selfLink(user) {
|
||||
const href = Endpoints.actorId(user);
|
||||
return {
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href,
|
||||
};
|
||||
}
|
||||
|
||||
// :TODO: only if ActivityPub is enabled
|
||||
_subscribeLink() {
|
||||
return {
|
||||
rel: 'http://ostatus.org/schema/1.0/subscribe',
|
||||
template: buildUrl(
|
||||
WellKnownLocations.Internal + '/ap/authorize_interaction?uri={uri}'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
_getAccountName(resource) {
|
||||
for (const re of this.acceptedResourceRegExps) {
|
||||
const m = resource.match(re);
|
||||
if (m && m.length === 2) {
|
||||
return m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -8,6 +8,7 @@ const SysProps = require('./system_property.js');
|
|||
const UserProps = require('./user_property');
|
||||
const Message = require('./message');
|
||||
const { getActiveConnections, AllConnections } = require('./client_connections');
|
||||
const Log = require('./logger').log;
|
||||
|
||||
// deps
|
||||
const _ = require('lodash');
|
||||
|
@ -349,6 +350,7 @@ class StatLog {
|
|||
// - resultType: 'obj' | 'count' (default='obj')
|
||||
// - limit: Limit returned results
|
||||
// - date: exact date to filter against
|
||||
// - dateNewer: Entries newer than this value
|
||||
// - order: 'timestamp' | 'timestamp_asc' | 'timestamp_desc' | 'random'
|
||||
// (default='timestamp')
|
||||
//
|
||||
|
@ -402,7 +404,9 @@ class StatLog {
|
|||
this.setNonPersistentSystemStat(SysProps.SystemLoadStats, loadStats);
|
||||
})
|
||||
.catch(err => {
|
||||
// :TODO: log me
|
||||
if (err) {
|
||||
Log.err({ error: err.message }, 'Error refreshing system stats');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -457,8 +461,9 @@ class StatLog {
|
|||
_refreshUserPrivateMailCount(client) {
|
||||
const MsgArea = require('./message_area');
|
||||
MsgArea.getNewMessageCountInAreaForUser(
|
||||
client.user.userId,
|
||||
client.user,
|
||||
Message.WellKnownAreaTags.Private,
|
||||
{ addrToOnly: false },
|
||||
(err, count) => {
|
||||
if (!err) {
|
||||
client.user.setProperty(UserProps.NewPrivateMailCount, count);
|
||||
|
@ -509,6 +514,11 @@ class StatLog {
|
|||
sql += ` AND DATE(timestamp, "localtime") = DATE("${filter.date.format(
|
||||
'YYYY-MM-DD'
|
||||
)}")`;
|
||||
} else if (filter.dateNewer) {
|
||||
filter.dateNewer = moment(filter.dateNewer);
|
||||
sql += ` AND DATE(timestamp, "localtime") > DATE("${filter.dateNewer.format(
|
||||
'YYYY-MM-DD'
|
||||
)}")`;
|
||||
}
|
||||
|
||||
if ('count' !== filter.resultType) {
|
||||
|
|
|
@ -19,6 +19,7 @@ exports.debugEscapedString = debugEscapedString;
|
|||
exports.stringFromNullTermBuffer = stringFromNullTermBuffer;
|
||||
exports.stringToNullTermBuffer = stringToNullTermBuffer;
|
||||
exports.renderSubstr = renderSubstr;
|
||||
exports.renderTruncate = renderTruncate;
|
||||
exports.renderStringLength = renderStringLength;
|
||||
exports.ansiRenderStringLength = ansiRenderStringLength;
|
||||
exports.formatByteSizeAbbr = formatByteSizeAbbr;
|
||||
|
@ -136,38 +137,37 @@ function stylizeString(s, style) {
|
|||
return s;
|
||||
}
|
||||
|
||||
function pad(s, len, padChar, justify, stringSGR, padSGR, useRenderLen) {
|
||||
function pad(s, len, padChar, justify, stringSGR, padSGR, useRenderLen = true) {
|
||||
len = len || 0;
|
||||
padChar = padChar || ' ';
|
||||
justify = justify || 'left';
|
||||
stringSGR = stringSGR || '';
|
||||
padSGR = padSGR || '';
|
||||
useRenderLen = _.isUndefined(useRenderLen) ? true : useRenderLen;
|
||||
|
||||
const renderLen = useRenderLen ? renderStringLength(s) : s.length;
|
||||
const padlen = len >= renderLen ? len - renderLen : 0;
|
||||
const padLen = len > renderLen ? len - renderLen : 0;
|
||||
|
||||
switch (justify) {
|
||||
case 'L':
|
||||
case 'left':
|
||||
s = `${stringSGR}${s}${padSGR}${Array(padlen).join(padChar)}`;
|
||||
s = `${stringSGR}${s}${padSGR}${padChar.repeat(padLen)}`;
|
||||
break;
|
||||
|
||||
case 'C':
|
||||
case 'center':
|
||||
case 'both':
|
||||
{
|
||||
const right = Math.ceil(padlen / 2);
|
||||
const left = padlen - right;
|
||||
const right = Math.ceil(padLen / 2);
|
||||
const left = padLen - right;
|
||||
s = `${padSGR}${Array(left + 1).join(
|
||||
padChar
|
||||
)}${stringSGR}${s}${padSGR}${Array(right + 1).join(padChar)}`;
|
||||
)}${stringSGR}${s}${padSGR}${padChar.repeat(right)}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'R':
|
||||
case 'right':
|
||||
s = `${padSGR}${Array(padlen).join(padChar)}${stringSGR}${s}`;
|
||||
s = `${padSGR}${padChar.repeat(padLen)}${stringSGR}${s}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
|
@ -290,6 +290,29 @@ function renderSubstr(str, start, length) {
|
|||
return out;
|
||||
}
|
||||
|
||||
const DefaultTruncateLen = 30;
|
||||
const DefaultTruncateOmission = '...';
|
||||
|
||||
function renderTruncate(str, options) {
|
||||
// shortcut for empty strings
|
||||
if (0 === str.length) {
|
||||
return str;
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
options.length = options.length || DefaultTruncateLen;
|
||||
options.omission = _.isString(options.omission)
|
||||
? options.omission
|
||||
: DefaultTruncateOmission;
|
||||
|
||||
let out = renderSubstr(str, 0, options.length - options.omission.length);
|
||||
if (out.length < str.length) {
|
||||
out += options.omission;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
//
|
||||
// Method to return the "rendered" length taking into account Pipe and ANSI color codes.
|
||||
//
|
||||
|
@ -464,7 +487,7 @@ function isAnsiLine(line) {
|
|||
// * Pipe codes
|
||||
// * Extended (CP437) ASCII - https://www.ascii-codes.com/
|
||||
// * Tabs
|
||||
// * Contigous 3+ spaces before the end of the line
|
||||
// * Contiguous 3+ spaces before the end of the line
|
||||
//
|
||||
function isFormattedLine(line) {
|
||||
if (renderStringLength(line) < line.length) {
|
||||
|
|
|
@ -10,7 +10,9 @@ module.exports = {
|
|||
ConfigChanged: 'codes.l33t.enigma.system.config_changed', // (config.hjson)
|
||||
MenusChanged: 'codes.l33t.enigma.system.menus_changed', // (menu.hjson)
|
||||
|
||||
// User - includes { user, ...}
|
||||
// User - includes { user, callback, ... } where user *is* the user instance in question
|
||||
NewUserPrePersist: 'codes.l33t.enigma.system.user_new_pre_persist',
|
||||
// User - includes { user, ...} where user is a *copy*
|
||||
NewUser: 'codes.l33t.enigma.system.user_new', // { ... }
|
||||
UserLogin: 'codes.l33t.enigma.system.user_login', // { ... }
|
||||
UserLogoff: 'codes.l33t.enigma.system.user_logoff', // { ... }
|
||||
|
|
|
@ -2,11 +2,12 @@
|
|||
'use strict';
|
||||
|
||||
// ENiGMA½
|
||||
const User = require('./user.js');
|
||||
const Config = require('./config.js').get;
|
||||
const Log = require('./logger.js').log;
|
||||
const { getAddressedToInfo } = require('./mail_util.js');
|
||||
const Message = require('./message.js');
|
||||
const User = require('./user');
|
||||
const Config = require('./config').get;
|
||||
const Log = require('./logger').log;
|
||||
const { getAddressedToInfo } = require('./mail_util');
|
||||
const Message = require('./message');
|
||||
const { Errors, ErrorReasons } = require('./enig_error'); // note: Only use ValidationFailed in this module!
|
||||
|
||||
// deps
|
||||
const fs = require('graceful-fs');
|
||||
|
@ -24,36 +25,66 @@ exports.validatePasswordSpec = validatePasswordSpec;
|
|||
const emptyFieldError = () => new Error('Field cannot be empty');
|
||||
|
||||
function validateNonEmpty(data, cb) {
|
||||
return cb(data && data.length > 0 ? null : emptyFieldError);
|
||||
return cb(
|
||||
data && data.length > 0
|
||||
? null
|
||||
: Errors.ValidationFailed('Field cannot be empty', ErrorReasons.ValueTooShort)
|
||||
);
|
||||
}
|
||||
|
||||
function validateMessageSubject(data, cb) {
|
||||
return cb(data && data.length > 1 ? null : new Error('Subject too short'));
|
||||
return cb(
|
||||
data && data.length > 1
|
||||
? null
|
||||
: Errors.ValidationFailed('Subject too short', ErrorReasons.ValueTooShort)
|
||||
);
|
||||
}
|
||||
|
||||
function validateUserNameAvail(data, cb) {
|
||||
const config = Config();
|
||||
if (!data || data.length < config.users.usernameMin) {
|
||||
cb(new Error('Username too short'));
|
||||
cb(Errors.ValidationFailed('Username too short', ErrorReasons.ValueTooShort));
|
||||
} else if (data.length > config.users.usernameMax) {
|
||||
// generally should be unreached due to view restraints
|
||||
return cb(new Error('Username too long'));
|
||||
return cb(
|
||||
Errors.ValidationFailed('Username too long', ErrorReasons.ValueTooLong)
|
||||
);
|
||||
} else {
|
||||
const usernameRegExp = new RegExp(config.users.usernamePattern);
|
||||
const invalidNames = config.users.newUserNames + config.users.badUserNames;
|
||||
|
||||
if (!usernameRegExp.test(data)) {
|
||||
return cb(new Error('Username contains invalid characters'));
|
||||
return cb(
|
||||
Errors.ValidationFailed(
|
||||
'Username contains invalid characters',
|
||||
ErrorReasons.ValueInvalid
|
||||
)
|
||||
);
|
||||
} else if (invalidNames.indexOf(data.toLowerCase()) > -1) {
|
||||
return cb(new Error('Username is blacklisted'));
|
||||
return cb(
|
||||
Errors.ValidationFailed(
|
||||
'Username is blacklisted',
|
||||
ErrorReasons.NotAllowed
|
||||
)
|
||||
);
|
||||
} else if (/^[0-9]+$/.test(data)) {
|
||||
return cb(new Error('Username cannot be a number'));
|
||||
return cb(
|
||||
Errors.ValidationFailed(
|
||||
'Username cannot be a number',
|
||||
ErrorReasons.ValueInvalid
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// a new user name cannot be an existing user name or an existing real name
|
||||
User.getUserIdAndNameByLookup(data, function userIdAndName(err) {
|
||||
if (!err) {
|
||||
// err is null if we succeeded -- meaning this user exists already
|
||||
return cb(new Error('Username unavailable'));
|
||||
return cb(
|
||||
Errors.ValidationFailed(
|
||||
'Username unavailable',
|
||||
ErrorReasons.NotAvailable
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return cb(null);
|
||||
|
@ -62,25 +93,41 @@ function validateUserNameAvail(data, cb) {
|
|||
}
|
||||
}
|
||||
|
||||
const invalidUserNameError = () => new Error('Invalid username');
|
||||
|
||||
function validateUserNameExists(data, cb) {
|
||||
if (0 === data.length) {
|
||||
return cb(invalidUserNameError());
|
||||
return cb(
|
||||
Errors.ValidationFailed('Invalid username', ErrorReasons.ValueTooShort)
|
||||
);
|
||||
}
|
||||
|
||||
User.getUserIdAndName(data, err => {
|
||||
return cb(err ? invalidUserNameError() : null);
|
||||
return cb(
|
||||
err
|
||||
? Errors.ValidationFailed(
|
||||
'Failed to find username',
|
||||
err.reasonCode || ErrorReasons.DoesNotExist
|
||||
)
|
||||
: null
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function validateUserNameOrRealNameExists(data, cb) {
|
||||
if (0 === data.length) {
|
||||
return cb(invalidUserNameError());
|
||||
return cb(
|
||||
Errors.ValidationFailed('Invalid username', ErrorReasons.ValueTooShort)
|
||||
);
|
||||
}
|
||||
|
||||
User.getUserIdAndNameByLookup(data, err => {
|
||||
return cb(err ? invalidUserNameError() : null);
|
||||
return cb(
|
||||
err
|
||||
? Errors.ValidationFailed(
|
||||
'Failed to find user',
|
||||
err.reasonCode || ErrorReasons.DoesNotExist
|
||||
)
|
||||
: null
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -90,7 +137,6 @@ function validateGeneralMailAddressedTo(data, cb) {
|
|||
// - Local username or real name
|
||||
// - Supported remote flavors such as FTN, email, ...
|
||||
//
|
||||
// :TODO: remove hard-coded FTN check here. We need a decent way to register global supported flavors with modules.
|
||||
const addressedToInfo = getAddressedToInfo(data);
|
||||
|
||||
if (addressedToInfo.name.length === 0) {
|
||||
|
@ -120,7 +166,9 @@ function validateEmailAvail(data, cb) {
|
|||
//
|
||||
const emailRegExp = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(.[a-z0-9-]+)*/;
|
||||
if (!emailRegExp.test(data)) {
|
||||
return cb(new Error('Invalid email address'));
|
||||
return cb(
|
||||
Errors.ValidationFailed('Invalid email address', ErrorReasons.ValueInvalid)
|
||||
);
|
||||
}
|
||||
|
||||
User.getUserIdsWithProperty(
|
||||
|
@ -128,9 +176,19 @@ function validateEmailAvail(data, cb) {
|
|||
data,
|
||||
function userIdsWithEmail(err, uids) {
|
||||
if (err) {
|
||||
return cb(new Error('Internal system error'));
|
||||
return cb(
|
||||
Errors.ValidationFailed(
|
||||
err.message,
|
||||
err.reasonCode || ErrorReasons.DoesNotExist
|
||||
)
|
||||
);
|
||||
} else if (uids.length > 0) {
|
||||
return cb(new Error('Email address not unique'));
|
||||
return cb(
|
||||
Errors.ValidationFailed(
|
||||
'Email address not unique',
|
||||
ErrorReasons.NotAvailable
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return cb(null);
|
||||
|
@ -140,25 +198,36 @@ function validateEmailAvail(data, cb) {
|
|||
|
||||
function validateBirthdate(data, cb) {
|
||||
// :TODO: check for dates in the future, or > reasonable values
|
||||
return cb(isNaN(Date.parse(data)) ? new Error('Invalid birthdate') : null);
|
||||
return cb(
|
||||
isNaN(Date.parse(data))
|
||||
? Errors.ValidationFailed('Invalid birthdate', ErrorReasons.ValueInvalid)
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function validatePasswordSpec(data, cb) {
|
||||
const config = Config();
|
||||
if (!data || data.length < config.users.passwordMin) {
|
||||
return cb(new Error('Password too short'));
|
||||
return cb(
|
||||
Errors.ValidationFailed('Password too short', ErrorReasons.ValueTooShort)
|
||||
);
|
||||
}
|
||||
|
||||
// check badpass, if avail
|
||||
fs.readFile(config.users.badPassFile, 'utf8', (err, passwords) => {
|
||||
if (err) {
|
||||
Log.warn({ error: err.message }, 'Cannot read bad pass file');
|
||||
Log.warn(
|
||||
{ error: err.message, path: config.users.badPassFile },
|
||||
'Cannot read bad pass file'
|
||||
);
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
passwords = passwords.toString().split(/\r\n|\n/g);
|
||||
if (passwords.includes(data)) {
|
||||
return cb(new Error('Password is too common'));
|
||||
return cb(
|
||||
Errors.ValidationFailed('Password is too common', ErrorReasons.NotAllowed)
|
||||
);
|
||||
}
|
||||
|
||||
return cb(null);
|
||||
|
|
|
@ -54,13 +54,28 @@ function TextView(options) {
|
|||
// |ABCDEFG| ^_ this.text.length
|
||||
// ^-- this.dimens.width
|
||||
//
|
||||
let renderLength = renderStringLength(s); // initial; may be adjusted below:
|
||||
|
||||
let textToDraw = _.isString(this.textMaskChar)
|
||||
? new Array(renderLength + 1).join(this.textMaskChar)
|
||||
let textToDraw;
|
||||
if (this.itemFormat) {
|
||||
textToDraw = pipeToAnsi(
|
||||
stringFormat(
|
||||
this.hasFocus && this.focusItemFormat
|
||||
? this.focusItemFormat
|
||||
: this.itemFormat,
|
||||
{
|
||||
text: stylizeString(
|
||||
s,
|
||||
this.hasFocus ? this.focusTextStyle : this.textStyle
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
textToDraw = _.isString(this.textMaskChar)
|
||||
? new Array(renderStringLength(s) + 1).join(this.textMaskChar)
|
||||
: stylizeString(s, this.hasFocus ? this.focusTextStyle : this.textStyle);
|
||||
}
|
||||
|
||||
renderLength = renderStringLength(textToDraw);
|
||||
const renderLength = renderStringLength(textToDraw);
|
||||
|
||||
if (renderLength >= this.dimens.width) {
|
||||
if (this.hasFocus) {
|
||||
|
@ -151,6 +166,11 @@ TextView.prototype.getData = function () {
|
|||
TextView.prototype.setText = function (text, redraw) {
|
||||
redraw = _.isBoolean(redraw) ? redraw : true;
|
||||
|
||||
// Don't bomb if text isn't defined, just treat as blank instead.
|
||||
if (_.isUndefined(text)) {
|
||||
text = '';
|
||||
}
|
||||
|
||||
if (!_.isString(text)) {
|
||||
// allow |text| to be numbers/etc.
|
||||
text = text.toString();
|
||||
|
|
|
@ -77,6 +77,32 @@ ToggleMenuView.prototype.setFocusItemIndex = function (index) {
|
|||
this.updateSelection();
|
||||
};
|
||||
|
||||
ToggleMenuView.prototype.setTrue = function () {
|
||||
this.setFocusItemIndex(1);
|
||||
this.updateSelection();
|
||||
};
|
||||
|
||||
ToggleMenuView.prototype.setFalse = function () {
|
||||
this.setFocusItemIndex(0);
|
||||
this.updateSelection();
|
||||
};
|
||||
|
||||
ToggleMenuView.prototype.isTrue = function () {
|
||||
return this.focusedItemIndex === 1;
|
||||
};
|
||||
|
||||
ToggleMenuView.prototype.setFromBoolean = function (bool) {
|
||||
return bool ? this.setTrue() : this.setFalse();
|
||||
};
|
||||
|
||||
ToggleMenuView.prototype.setYes = function () {
|
||||
return this.setTrue();
|
||||
};
|
||||
|
||||
ToggleMenuView.prototype.setNo = function () {
|
||||
return this.setFalse();
|
||||
};
|
||||
|
||||
ToggleMenuView.prototype.setFocus = function (focused) {
|
||||
ToggleMenuView.super_.prototype.setFocus.call(this, focused);
|
||||
|
||||
|
|
|
@ -122,13 +122,13 @@ exports.getModule = class UploadModule extends MenuModule {
|
|||
);
|
||||
if (errView) {
|
||||
if (err) {
|
||||
errView.setText(err.message);
|
||||
errView.setText(err.friendlyText);
|
||||
} else {
|
||||
errView.clearText();
|
||||
}
|
||||
}
|
||||
|
||||
return cb(null);
|
||||
return cb(err, null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
136
core/user.js
136
core/user.js
|
@ -19,6 +19,9 @@ const _ = require('lodash');
|
|||
const moment = require('moment');
|
||||
const sanatizeFilename = require('sanitize-filename');
|
||||
const ssh2 = require('ssh2');
|
||||
const AvatarGenerator = require('avatar-generator');
|
||||
const paths = require('path');
|
||||
const fse = require('fs-extra');
|
||||
|
||||
module.exports = class User {
|
||||
constructor() {
|
||||
|
@ -45,6 +48,7 @@ module.exports = class User {
|
|||
|
||||
static get PBKDF2() {
|
||||
return {
|
||||
// :TODO: bump up iterations for all new PWs
|
||||
iterations: 1000,
|
||||
keyLen: 128,
|
||||
saltLen: 32,
|
||||
|
@ -531,12 +535,33 @@ module.exports = class User {
|
|||
|
||||
return callback(null, trans);
|
||||
},
|
||||
function newUserPreEvent(trans, callback) {
|
||||
const eventName = Events.getSystemEvents().NewUserPrePersist;
|
||||
const subCount = Events.listenerCount(eventName);
|
||||
if (subCount < 1) {
|
||||
return callback(null, trans);
|
||||
}
|
||||
|
||||
let returned = 0;
|
||||
const cbWrapper = e => {
|
||||
++returned;
|
||||
if (returned >= subCount) {
|
||||
return callback(e, trans);
|
||||
}
|
||||
};
|
||||
|
||||
Events.emit(eventName, {
|
||||
user: self,
|
||||
sessionId: createUserInfo.sessionId,
|
||||
callback: cbWrapper,
|
||||
});
|
||||
},
|
||||
function saveAll(trans, callback) {
|
||||
self.persistWithTransaction(trans, err => {
|
||||
return callback(err, trans);
|
||||
});
|
||||
},
|
||||
function sendEvent(trans, callback) {
|
||||
function newUserEvent(trans, callback) {
|
||||
Events.emit(Events.getSystemEvents().NewUser, {
|
||||
user: Object.assign({}, self, {
|
||||
sessionId: createUserInfo.sessionId,
|
||||
|
@ -655,6 +680,102 @@ module.exports = class User {
|
|||
);
|
||||
}
|
||||
|
||||
updateActivityPubKeyPairProperties(cb) {
|
||||
crypto.generateKeyPair(
|
||||
'rsa',
|
||||
{
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
},
|
||||
(err, publicKey, privateKey) => {
|
||||
if (!err) {
|
||||
this.setProperty(UserProps.PrivateActivityPubSigningKey, privateKey);
|
||||
this.setProperty(UserProps.PublicActivityPubSigningKey, publicKey);
|
||||
}
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
generateNewRandomAvatar(cb) {
|
||||
const spritesPath = _.get(Config(), 'users.avatars.spritesPath');
|
||||
const storagePath = _.get(Config(), 'users.avatars.storagePath');
|
||||
|
||||
if (!spritesPath || !storagePath) {
|
||||
return cb(
|
||||
Errors.MissingConfig(
|
||||
'Cannot generate new avatar: Missing path(s) in configuration'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async.waterfall(
|
||||
[
|
||||
callback => {
|
||||
return fse.mkdirs(storagePath, err => {
|
||||
return callback(err);
|
||||
});
|
||||
},
|
||||
callback => {
|
||||
const avatar = new AvatarGenerator({
|
||||
parts: [
|
||||
'background',
|
||||
'face',
|
||||
'clothes',
|
||||
'head',
|
||||
'hair',
|
||||
'eye',
|
||||
'mouth',
|
||||
],
|
||||
partsLocation: spritesPath,
|
||||
imageExtension: '.png',
|
||||
});
|
||||
|
||||
const userSex = (
|
||||
this.getProperty(UserProps.Sex) || 'M'
|
||||
).toUpperCase();
|
||||
|
||||
const variant = userSex[0] === 'M' ? 'male' : 'female';
|
||||
const stableId = `user#${this.userId.toString()}`;
|
||||
|
||||
avatar
|
||||
.generate(stableId, variant)
|
||||
.then(image => {
|
||||
const filename = `user-avatar-${this.userId}.png`;
|
||||
const outPath = paths.join(storagePath, filename);
|
||||
image.resize(640, 640);
|
||||
image.toFile(outPath, err => {
|
||||
if (!err) {
|
||||
Log.info(
|
||||
{
|
||||
userId: this.userId,
|
||||
username: this.username,
|
||||
outPath,
|
||||
},
|
||||
`New avatar generated for ${this.username}`
|
||||
);
|
||||
}
|
||||
return callback(err, outPath);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
return callback(err);
|
||||
});
|
||||
},
|
||||
],
|
||||
(err, outPath) => {
|
||||
return cb(err, outPath);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
persistProperties(properties, transOrDb, cb) {
|
||||
if (!_.isFunction(cb) && _.isFunction(transOrDb)) {
|
||||
cb = transOrDb;
|
||||
|
@ -814,6 +935,15 @@ module.exports = class User {
|
|||
);
|
||||
}
|
||||
|
||||
static getUserByUsername(username, cb) {
|
||||
User.getUserIdAndName(username, (err, userId) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
return User.getUser(userId, cb);
|
||||
});
|
||||
}
|
||||
|
||||
static getUserIdAndNameByRealName(realName, cb) {
|
||||
userDb.get(
|
||||
`SELECT id, user_name
|
||||
|
@ -916,8 +1046,8 @@ module.exports = class User {
|
|||
userIds.push(row.user_id);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
return cb(null, userIds);
|
||||
err => {
|
||||
return cb(err, userIds);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
const Config = require('./config.js').get;
|
||||
const getServer = require('./listening_server.js').getServer;
|
||||
const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName;
|
||||
const { WellKnownLocations } = require('./servers/content/web');
|
||||
const {
|
||||
createToken,
|
||||
deleteToken,
|
||||
|
@ -16,6 +17,7 @@ const { sendMail } = require('./email.js');
|
|||
const UserProps = require('./user_property.js');
|
||||
const Log = require('./logger.js').log;
|
||||
const { getConnectionByUserId } = require('./client_connections.js');
|
||||
const { buildUrl } = require('./web_util');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
|
@ -74,9 +76,9 @@ module.exports = class User2FA_OTPWebRegister {
|
|||
});
|
||||
},
|
||||
(token, textTemplate, htmlTemplate, callback) => {
|
||||
const webServer = getWebServer();
|
||||
const registerUrl = webServer.instance.buildUrl(
|
||||
`/_internal/enable_2fa_otp?token=${token}&otpType=${otpType}`
|
||||
const registerUrl = buildUrl(
|
||||
WellKnownLocations.Internal +
|
||||
`/2fa/enable_2fa_otp?token=${token}&otpType=${otpType}`
|
||||
);
|
||||
|
||||
const replaceTokens = s => {
|
||||
|
@ -168,7 +170,9 @@ module.exports = class User2FA_OTPWebRegister {
|
|||
return User2FA_OTPWebRegister.accessDenied(webServer, resp);
|
||||
}
|
||||
|
||||
const postUrl = webServer.instance.buildUrl('/_internal/enable_2fa_otp');
|
||||
const postUrl = buildUrl(
|
||||
WellKnownLocations.Internal + '/2fa/enable_2fa_otp'
|
||||
);
|
||||
const config = Config();
|
||||
return webServer.instance.routeTemplateFilePage(
|
||||
_.get(config, 'users.twoFactorAuth.otp.registerPageTemplate'),
|
||||
|
@ -294,12 +298,12 @@ ${backupCodes}
|
|||
[
|
||||
{
|
||||
method: 'GET',
|
||||
path: /^\/_internal\/enable_2fa_otp\?token=[a-f0-9]+&otpType=[a-zA-Z0-9_]+$/,
|
||||
path: /^\/_enig\/2fa\/enable_2fa_otp\?token=[a-f0-9]+&otpType=[a-zA-Z0-9_]+$/,
|
||||
handler: User2FA_OTPWebRegister.routeRegisterGet,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: /^\/_internal\/enable_2fa_otp$/,
|
||||
path: /^\/_enig\/2fa\/enable_2fa_otp$/,
|
||||
handler: User2FA_OTPWebRegister.routeRegisterPost,
|
||||
},
|
||||
].forEach(r => {
|
||||
|
|
|
@ -90,7 +90,7 @@ exports.getModule = class UserConfigModule extends MenuModule {
|
|||
var newFocusId;
|
||||
if (errMsgView) {
|
||||
if (err) {
|
||||
errMsgView.setText(err.message);
|
||||
errMsgView.setText(err.friendlyText);
|
||||
|
||||
if (err.view.getId() === MciCodeIds.PassConfirm) {
|
||||
newFocusId = MciCodeIds.Password;
|
||||
|
@ -102,7 +102,8 @@ exports.getModule = class UserConfigModule extends MenuModule {
|
|||
errMsgView.clearText();
|
||||
}
|
||||
}
|
||||
cb(newFocusId);
|
||||
|
||||
return cb(err, newFocusId);
|
||||
},
|
||||
|
||||
//
|
||||
|
@ -233,7 +234,11 @@ exports.getModule = class UserConfigModule extends MenuModule {
|
|||
function populateViews(callback) {
|
||||
const user = self.client.user;
|
||||
|
||||
self.setViewText('menu', MciCodeIds.RealName, user.realName(false) || '');
|
||||
self.setViewText(
|
||||
'menu',
|
||||
MciCodeIds.RealName,
|
||||
user.realName(false) || ''
|
||||
);
|
||||
self.setViewText(
|
||||
'menu',
|
||||
MciCodeIds.BirthDate,
|
||||
|
|
|
@ -66,4 +66,12 @@ module.exports = {
|
|||
AuthFactor2OTP: 'auth_factor2_otp', // If present, OTP type for 2FA. See OTPTypes
|
||||
AuthFactor2OTPSecret: 'auth_factor2_otp_secret', // Secret used in conjunction with OTP 2FA
|
||||
AuthFactor2OTPBackupCodes: 'auth_factor2_otp_backup', // JSON array of backup codes
|
||||
|
||||
PublicActivityPubSigningKey: 'public_key_activitypub_sign_rsa_pem', // RSA public key for ActivityPub signing
|
||||
PrivateActivityPubSigningKey: 'private_key_activitypub_sign_rsa_pem', // RSA private key (corresponding to PublicActivityPubSigningKey)
|
||||
|
||||
AvatarImageUrl: 'user_avatar_image',
|
||||
|
||||
ActivityPubSettings: 'activitypub_settings', // JSON object (above); see ActivityPubSettings in activitypub/settings.js
|
||||
ActivityPubActorId: 'activitypub_actor_id', // Actor ID representing this users
|
||||
};
|
||||
|
|
|
@ -11,7 +11,6 @@ const pipeToAnsi = require('./color_codes.js').pipeToAnsi;
|
|||
// deps
|
||||
const util = require('util');
|
||||
const _ = require('lodash');
|
||||
const { throws } = require('assert');
|
||||
|
||||
exports.VerticalMenuView = VerticalMenuView;
|
||||
|
||||
|
@ -99,12 +98,20 @@ function VerticalMenuView(options) {
|
|||
sgr = index === self.focusedItemIndex ? self.getFocusSGR() : self.getSGR();
|
||||
}
|
||||
|
||||
if (this.hasTextOverflow()) {
|
||||
text = strUtil.renderTruncate(text, {
|
||||
length: this.dimens.width,
|
||||
omission: this.textOverflow,
|
||||
});
|
||||
}
|
||||
|
||||
text = `${sgr}${strUtil.pad(
|
||||
`${text}${this.styleSGR1}`,
|
||||
this.dimens.width,
|
||||
this.fillChar,
|
||||
this.justify
|
||||
)}`;
|
||||
|
||||
self.client.term.write(`${ansi.goto(item.row, self.position.col)}${text}`);
|
||||
this.setRenderCacheItem(index, text, item.focused);
|
||||
};
|
||||
|
@ -138,18 +145,16 @@ VerticalMenuView.prototype.redraw = function () {
|
|||
// erase old items
|
||||
// :TODO: optimize this: only needed if a item is removed or new max width < old.
|
||||
if (this.oldDimens) {
|
||||
const blank = new Array(Math.max(this.oldDimens.width, this.dimens.width)).join(
|
||||
' '
|
||||
);
|
||||
let seq = ansi.goto(this.position.row, this.position.col) + this.getSGR() + blank;
|
||||
let row = this.position.row + 1;
|
||||
const blank = ' '.repeat(Math.max(this.oldDimens.width, this.dimens.width));
|
||||
let row = this.position.row;
|
||||
const endRow = row + this.oldDimens.height - 2;
|
||||
|
||||
while (row <= endRow) {
|
||||
seq += ansi.goto(row, this.position.col) + blank;
|
||||
this.client.term.write(
|
||||
ansi.goto(row, this.position.col) + this.getSGR() + blank
|
||||
);
|
||||
row += 1;
|
||||
}
|
||||
this.client.term.write(seq);
|
||||
delete this.oldDimens;
|
||||
}
|
||||
|
||||
|
@ -242,6 +247,7 @@ VerticalMenuView.prototype.setItems = function (items) {
|
|||
if (this.items && this.items.length) {
|
||||
this.oldDimens = Object.assign({}, this.dimens);
|
||||
}
|
||||
this.focusedItemIndex = 0;
|
||||
|
||||
VerticalMenuView.super_.prototype.setItems.call(this, items);
|
||||
|
||||
|
@ -401,6 +407,12 @@ VerticalMenuView.prototype.focusLast = function () {
|
|||
return VerticalMenuView.super_.prototype.focusLast.call(this);
|
||||
};
|
||||
|
||||
VerticalMenuView.prototype.setTextOverflow = function (overflow) {
|
||||
VerticalMenuView.super_.prototype.setTextOverflow.call(this, overflow);
|
||||
|
||||
this.positionCacheExpired = true;
|
||||
};
|
||||
|
||||
VerticalMenuView.prototype.setFocusItems = function (items) {
|
||||
VerticalMenuView.super_.prototype.setFocusItems.call(this, items);
|
||||
|
||||
|
|
10
core/view.js
10
core/view.js
|
@ -127,6 +127,14 @@ View.prototype.getId = function () {
|
|||
return this.id;
|
||||
};
|
||||
|
||||
View.prototype.getWidth = function () {
|
||||
return this.dimens.width;
|
||||
};
|
||||
|
||||
View.prototype.getHeight = function () {
|
||||
return this.dimens.height;
|
||||
};
|
||||
|
||||
View.prototype.setPosition = function (pos) {
|
||||
//
|
||||
// Allow the following forms: [row, col], { row : r, col : c }, or (row, col)
|
||||
|
@ -142,7 +150,7 @@ View.prototype.setPosition = function (pos) {
|
|||
this.position.col = parseInt(arguments[1], 10);
|
||||
}
|
||||
|
||||
// sanatize
|
||||
// sanitize
|
||||
this.position.row = Math.max(this.position.row, 1);
|
||||
this.position.col = Math.max(this.position.col, 1);
|
||||
this.position.row = Math.min(this.position.row, this.client.term.termHeight);
|
||||
|
|
|
@ -385,19 +385,18 @@ function ViewController(options) {
|
|||
this.validateView = function (view, cb) {
|
||||
if (view && _.isFunction(view.validate)) {
|
||||
view.validate(view.getData(), function validateResult(err) {
|
||||
var viewValidationListener =
|
||||
const viewValidationListener =
|
||||
self.client.currentMenuModule.menuMethods.viewValidationListener;
|
||||
if (_.isFunction(viewValidationListener)) {
|
||||
if (err) {
|
||||
err.view = view; // pass along the view that failed
|
||||
err.friendlyText = err.reason || err.message;
|
||||
}
|
||||
|
||||
viewValidationListener(
|
||||
err,
|
||||
function validationComplete(newViewFocusId) {
|
||||
cb(err, newViewFocusId);
|
||||
}
|
||||
);
|
||||
viewValidationListener(err, (err, newFocusedViewId) => {
|
||||
// validator may have updated |err|
|
||||
return cb(err, newFocusedViewId);
|
||||
});
|
||||
} else {
|
||||
cb(err);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
const { PluginModule } = require('./plugin_module');
|
||||
const Config = require('./config').get;
|
||||
|
||||
module.exports = class WebHandlerModule extends PluginModule {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
init(webServer, cb) {
|
||||
// to be implemented!
|
||||
this.webServer = webServer;
|
||||
return cb(null);
|
||||
}
|
||||
|
||||
static isEnabled(handlerName) {
|
||||
const config = Config();
|
||||
const handlers = config.contentServers?.web?.handlers;
|
||||
return handlers && true === handlers[handlerName]?.enabled;
|
||||
}
|
||||
|
||||
static getWebServer() {
|
||||
const { getServer } = require('./listening_server');
|
||||
const WebServerPackageName = require('./servers/content/web').moduleInfo
|
||||
.packageName;
|
||||
const ws = getServer(WebServerPackageName);
|
||||
if (ws) {
|
||||
return ws.instance;
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
const Logger = require('./logger');
|
||||
const Config = require('./config').get;
|
||||
|
||||
// deps
|
||||
const paths = require('path');
|
||||
const bunyan = require('bunyan');
|
||||
const { get } = require('lodash');
|
||||
|
||||
module.exports = class WebLog {
|
||||
static createWebLog() {
|
||||
const config = Config();
|
||||
const logPath = config.paths.logs;
|
||||
const rotatingFile = get(config, 'contentServers.web.logging.rotatingFile');
|
||||
|
||||
rotatingFile.path = paths.join(logPath, rotatingFile.fileName);
|
||||
|
||||
const serializers = Logger.standardSerializers();
|
||||
serializers.req = bunyan.stdSerializers.req;
|
||||
serializers.res = bunyan.stdSerializers.res;
|
||||
|
||||
const webLog = bunyan.createLogger({
|
||||
name: 'ENiGMA½',
|
||||
streams: [rotatingFile],
|
||||
serializers,
|
||||
});
|
||||
|
||||
return webLog;
|
||||
}
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue