Compare commits

..

258 Commits

Author SHA1 Message Date
Bryan Ashby 19b6f93be4
Sync up with master 2023-10-12 20:40:57 -06:00
Bryan Ashby 2bf4540707
Sync up with master 2023-10-11 19:52:21 -06:00
Bryan Ashby de4e3a1296
Sync up with master 2023-09-02 23:08:15 -06:00
Bryan Ashby cc42812cea
Sync up with master 2023-08-27 17:25:58 -06:00
Bryan Ashby fcd961c854
Cleanup 2023-08-27 10:47:42 -06:00
Bryan Ashby 65ea0192fa
Reject Follow request support 2023-08-26 22:29:19 -06:00
Bryan Ashby 460070e61d
Users can now accept a follow request; Deny and remove next 2023-08-26 18:49:07 -06:00
Bryan Ashby d5ab53ecad
Fix a couple MCI code display issues 2023-08-24 14:18:03 -06:00
Bryan Ashby ea6ab5a146
Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-08-24 13:07:41 -06:00
Bryan Ashby 6ea3e504e5
Well, that was dumb; Join without ',' 2023-08-24 13:07:17 -06:00
Bryan Ashby 28832e77f3 Add scheduledEventOptimizeDatabases() 2023-08-24 12:58:44 -06:00
Bryan Ashby 7961fa48db Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-08-24 12:49:28 -06:00
Bryan Ashby 2f9871e005
Updates 2023-08-24 12:12:48 -06:00
Bryan Ashby c25c600417
Replys to ActivityPub should start with @someone 2023-08-24 12:12:32 -06:00
Bryan Ashby 4d97922933
Better gathering of lines; don't add extra line terms 2023-08-24 12:12:09 -06:00
Bryan Ashby 0035ef4f39
Fix HTTPS signing for POSTS 2023-08-24 12:11:23 -06:00
Bryan Ashby 2964c01841
Merge branch 'master' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-08-23 21:49:54 -06:00
Bryan Ashby 42b1b65cdc
Merge branch 'master' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-08-23 21:45:20 -06:00
Bryan Ashby 4bd55f1554
Fixes after merge 2023-08-23 21:04:22 -06:00
Bryan Ashby cded6a59b5 Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-05-11 08:47:14 -06:00
Bryan Ashby d5a7905225
Merge branch 'master' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-05-11 08:46:10 -06:00
Bryan Ashby 4501140a15 Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-26 21:40:51 -06:00
Bryan Ashby d5bffb1719
Fix up warning and update Buffer() to Buffer.from() 2023-04-26 21:40:37 -06:00
Bryan Ashby 0858916490 Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-26 20:01:58 -06:00
Bryan Ashby a5a3a63b00
Minor sys log updates 2023-04-26 19:11:38 -06:00
Bryan Ashby 0c1785c462
More log updates 2023-04-26 19:08:31 -06:00
Bryan Ashby 60b17a64ae Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-25 11:46:37 -06:00
Bryan Ashby a5a72d8270
Split out logging a bit, fix accept header parsing 2023-04-25 11:46:19 -06:00
Bryan Ashby 31f10d0c78
Fixes 2023-04-25 11:46:03 -06:00
Bryan Ashby dd8ab851f2
Less restrictive for subject capture...again 2023-04-25 11:45:13 -06:00
Bryan Ashby fa95616933 Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-21 12:00:11 -06:00
Bryan Ashby ed7a728bd7
Less restrictive for subject capture 2023-04-21 11:56:28 -06:00
Bryan Ashby 6dde005f21
Capture '@' also 2023-04-20 20:56:25 -06:00
Bryan Ashby e5fdc2450c Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-20 20:43:09 -06:00
Bryan Ashby 821d6cef70
Use follow-redirects to ensure we follow redirects during Actor lookup et al. 2023-04-20 20:41:46 -06:00
Bryan Ashby beb28c9696
Include message ID in log, fix message 2023-04-20 20:33:19 -06:00
Bryan Ashby 95c249fc23
Better to/subject/etc. 2023-04-20 20:32:50 -06:00
Bryan Ashby db3387d6c5
Allow application/json 2023-04-20 19:30:08 -06:00
Bryan Ashby 052c2d5a9b Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-19 19:47:18 -06:00
Bryan Ashby 32bb0c4937
Dumb fix...again 2023-04-19 19:46:59 -06:00
Bryan Ashby 33dca44286 Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-19 13:46:23 -06:00
Bryan Ashby 71076c8e90
Dumb fix 2023-04-19 13:46:11 -06:00
Bryan Ashby 22e7689f01 Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-19 13:21:51 -06:00
Bryan Ashby faf8ccaaf8
Updated sig check 2 2023-04-19 13:19:02 -06:00
Bryan Ashby ea3d2cffec Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-19 12:37:37 -06:00
Bryan Ashby 1c27891f15
Test sig check 2023-04-19 12:37:11 -06:00
Bryan Ashby 999a87c7ef Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-14 08:44:23 -06:00
Bryan Ashby c4553a01a5
Fix callback 2023-04-14 08:44:08 -06:00
Bryan Ashby 7499ffb56b Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-10 13:18:19 -06:00
Bryan Ashby 84c6478849
Minor fixes around images 2023-04-10 13:18:06 -06:00
Bryan Ashby 5e4c94210a Merge branch '459-activitypub-integration' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-10 11:48:25 -06:00
Bryan Ashby d035fc5245 Merge branch 'master' of ssh://numinibsd/git/base/enigma-bbs into 459-activitypub-integration 2023-04-10 11:47:48 -06:00
Bryan Ashby 0b59d0e3b5
Strip codes from auto sig for summary 2023-04-10 11:47:27 -06:00
Bryan Ashby 97f3a1e63a
Update copyright to 2023 2023-04-10 11:41:10 -06:00
Bryan Ashby cc20ffc85f
Fix addressed to WFC count 2023-03-26 14:59:03 -06:00
Bryan Ashby 127d09a09b
Ensure AP setup for user if not already at toggle time (fix) 2023-03-19 13:54:13 -06:00
Bryan Ashby 35c97f7035
Ensure AP setup for user if not already at toggle time 2023-03-19 13:50:44 -06:00
Bryan Ashby 94842afd7e
Organized and updated upgrade docs 2023-03-19 13:39:21 -06:00
Bryan Ashby 81edff48fe
WIP doc updates, need sorting 2023-03-19 01:09:33 -06:00
Bryan Ashby cca850dd78
Some docs 2023-03-19 00:05:52 -06:00
Bryan Ashby 26c44b91a6
Bunch more removals of unneeded webServer instance 2023-03-18 14:58:37 -06:00
Bryan Ashby fb02fc599a
Remove need for WebServer in a lot of areas, oputil ap condition functional
* Getting domain, URLs, etc. for local web server do not need a web server instance themselves
* fix up oputil
2023-03-18 14:30:37 -06:00
Bryan Ashby e915527427
oputil ap condition USERNAME 2023-03-17 18:40:54 -06:00
Bryan Ashby 8c609b79bb
oputil updates for AP enabled/disabled, small change to 2FA info 2023-03-14 22:22:11 -06:00
Bryan Ashby 10e2abffc6
Trim ActivityPub SharedInbox collection (Notes) along with assoc message area 2023-03-14 21:58:03 -06:00
Bryan Ashby 5314fb4ad9
Browse public messages from AP menu; Fix ACS 2023-03-13 13:33:54 -06:00
Bryan Ashby 8b6d564ebf
Handling Update/Delete better 2023-03-13 12:39:36 -06:00
Bryan Ashby 3212d809df
Better validation for 'Update' 2023-03-12 14:42:34 -06:00
Bryan Ashby ea9d826a7c
Updates around Deletes 2023-03-12 14:22:41 -06:00
Bryan Ashby 6afbb29139
Cleanup 2023-03-06 17:19:20 -07:00
Bryan Ashby b0fff20a02
More updates on Deletes, ActivityPub ACS in menu 2023-02-27 13:03:27 -07:00
Bryan Ashby 65e5fa1b77
New ACS for ActivtiyPub enabled check 2023-02-27 12:59:35 -07:00
Bryan Ashby a968f21957
A few bugs fixed with Note storage 2023-02-26 21:29:07 -07:00
Bryan Ashby 63cfc904aa
More QoL 2023-02-25 13:25:19 -07:00
Bryan Ashby ec68f8c80c
All modes 2023-02-25 13:15:28 -07:00
Bryan Ashby 0af70b0f57
Various QoL fixes in UI
* Update AP menus, add options
* AP menu prompt to show user's Actor/Subject name
* Allow MLTEV to have focus SGRs
2023-02-25 13:07:13 -07:00
Bryan Ashby 0263d8bc5e
Various fixes:
* Fix socket hangup bug in http_util requests
* Disallow users to follow themselves
* GET's to /followers, /following, etc. are not signed; don't try to enforce it
* Fix a couple callbacks
* WIP: Start more on Delete of inbox items
2023-02-25 11:50:30 -07:00
Bryan Ashby a205445dd1
Fix up message header for public AP, Undo following, some bugs around following local Actors... 2023-02-24 22:54:19 -07:00
Bryan Ashby 5b08d21966
Prevent double callback 2023-02-24 21:43:58 -07:00
Bryan Ashby 60e8b787d9
Merge branch '459-activitypub-integration' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-02-24 15:40:57 -07:00
Nathan Byrd 24645c98b0 Restored achievements.hjson 2023-02-24 08:21:37 -06:00
Bryan Ashby 0b1794c210
Merge branch '459-activitypub-integration' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-02-23 22:21:02 -07:00
Bryan Ashby 22349a23ec
New ACS: SE allows checking if various services are enabled 2023-02-23 22:20:54 -07:00
Bryan Ashby 86d2aeb9de
Oops, include ActivityPub in confs! 2023-02-23 20:48:07 -07:00
Bryan Ashby 8822a7cc3d
Tidy 2023-02-23 20:47:36 -07:00
Nathan Byrd 43cdaf0c5d Minor cleanup items 2023-02-22 12:59:17 -06:00
Bryan Ashby ccd229d7c6
Docs, tidy 2023-02-20 21:49:41 -07:00
Bryan Ashby f264e4886e
Fix 'yes'/'no' toggles, consts for well known conf tags/etc. 2023-02-20 20:09:58 -07:00
Bryan Ashby 2495430fae
Fix new user pre-creation event
* We're abusing Events a bit here, so have to manually track callbacks
2023-02-20 19:14:46 -07:00
Bryan Ashby e35fc5bf41
Fix password scrubbing 2023-02-20 19:14:36 -07:00
Bryan Ashby 6e53c25d99
Default config sets users to AP disabled by default; users can opt-in 2023-02-20 19:14:11 -07:00
Bryan Ashby ad1ab1f0c5
Default to ActivityPub disabled; +op needs to opt-in 2023-02-20 19:13:39 -07:00
Bryan Ashby c9f9eb9e17
Fix SQL bug 2023-02-20 17:09:34 -07:00
Bryan Ashby 40e07d7d84
Format HJSON 2023-02-20 16:39:51 -07:00
Bryan Ashby 777df9f879
Tidy names 2023-02-20 16:36:34 -07:00
Bryan Ashby 30cb21f092
Sync up 2023-02-20 16:12:07 -07:00
Bryan Ashby 036a3dcd58
Remove actor_cache; use a special collection 2023-02-20 16:01:16 -07:00
Nathan Byrd 8668eedce2 New submenu for activitypub 2023-02-20 16:21:42 -06:00
Bryan Ashby 51c58b5d8a
Fixes 2023-02-20 14:28:18 -07:00
Nathan Byrd 3d1ac922dc Added default overflow to theme 2023-02-20 13:54:35 -06:00
Nathan Byrd c79bb1e99f Added documentation for overflow and made consistent between views 2023-02-20 13:49:05 -06:00
Nathan Byrd 7a189b238f Several cosmetic fixes for off-by-one in padding and display issues when resetting lists 2023-02-20 13:29:03 -06:00
Nathan Byrd e963f18ba4 Several cosmetic fixes for off-by-one in padding and display issues when resetting lists 2023-02-20 13:08:11 -06:00
Bryan Ashby 53eff2715a
Docs 2023-02-19 19:55:53 -07:00
Bryan Ashby 62735411f6
Fix longstanding itemFormat/focusItemFormat issue 2023-02-19 19:54:46 -07:00
Bryan Ashby 560d608cd2
Better HTML stripping, fix display of summary, etc. 2023-02-17 23:18:24 -07:00
Bryan Ashby e8c42a9b2e
ActivityPub Social Manager, and many updates to Search functionality
* Manage Following/Followers (WIP, bugs, some missing functionality)
* Search for Actors (WIP, some bugs)
2023-02-17 21:46:24 -07:00
Bryan Ashby 2aec375bee
Driveby: getItem(x) vs getItems()[x] 2023-02-17 20:53:57 -07:00
Bryan Ashby 69cb0c5907
default timeout 2023-02-12 17:51:36 -07:00
Bryan Ashby e90f42c53c
Merge branch '459-activitypub-integration' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-02-12 17:48:42 -07:00
Bryan Ashby 194a5b012e
Add image URL editor for AcitvityPub user config 2023-02-12 17:48:34 -07:00
Nathan Byrd b577c1b847 Added default connection timeout and new config option outbound.connectionTimeoutMilliseconds 2023-02-12 13:38:52 -06:00
Bryan Ashby df55c3fa6d
New MCI for AP subject, show subj and custom info format on config 2023-02-11 20:27:50 -07:00
Bryan Ashby a09fe4894f
Durp 2023-02-10 22:29:33 -07:00
Nathan Byrd 6466220b6d Revert "First steps toward search"
This reverts commit 87034967ae.
2023-02-10 21:34:49 -06:00
Nathan Byrd 87034967ae First steps toward search 2023-02-10 16:51:50 -06:00
Bryan Ashby 1fcadef8d0
Fix local-to-local user in public area 2023-02-09 20:36:11 -07:00
Bryan Ashby c0914af002
Fix a few more inbox/addressing bugs, cleanup, add methtod to request follow 2023-02-08 21:32:54 -07:00
Bryan Ashby fb039c1abc
Fix dumb bugs 2023-02-08 20:10:50 -07:00
Bryan Ashby 9b08cf827b
Clean up Actor cache 2023-02-08 17:19:12 -07:00
Bryan Ashby c5f0e0e6ef
Rework most of the ActivityPub routing handling 2023-02-08 12:53:56 -07:00
Bryan Ashby 39a49f00be
Cleanup 2023-02-06 22:45:01 -07:00
Bryan Ashby c9b3c9bc41
Better context check 2023-02-06 22:18:09 -07:00
Bryan Ashby 1b684e2f2b
Clean up contexts in objects 2023-02-06 18:27:12 -07:00
Bryan Ashby 926f45b917
Clean up URLs to use central area, less confusion 2023-02-06 14:34:18 -07:00
Bryan Ashby 834dfd693f
Fix a dumb typo 2023-02-05 21:17:57 -07:00
Bryan Ashby 27da2bb108
Don't log entire JSON payloads 2023-02-05 21:13:34 -07:00
Bryan Ashby 0402de7444
Ability to send/recv public messages in the AP shared inbox areaTag
* Optional subjects
* Resolving followers
* Various cleanup and tidy
2023-02-05 21:10:51 -07:00
Bryan Ashby 36ebda5269
Fix shared inbox delivery for private + public 2023-02-05 14:43:46 -07:00
Bryan Ashby 41cd0f7f33
Fix local Actor URLs, add addFollowing() API 2023-02-05 10:42:30 -07:00
Bryan Ashby bd2dc27477
Add getRemoteCollectionStats() and usage 2023-02-04 23:16:44 -07:00
Bryan Ashby 24de0fa0bf
Merge branch '459-activitypub-integration' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-02-04 22:55:20 -07:00
Bryan Ashby 99ae973396
Handle Update of Notes, store Activites as-is, better shared mailbox delivery and DRY 2023-02-04 22:55:11 -07:00
Nathan Byrd 77b0e6dd23 Merge branch '459-activitypub-integration' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-02-04 19:34:09 -06:00
Nathan Byrd 877e2ca61a Added followers and following 2023-02-04 19:33:56 -06:00
Bryan Ashby c3335ce062
Hopefully better microformat handling outgoing 2023-02-04 15:17:59 -07:00
Bryan Ashby a24ec5fd67
Some hardening and Note import improvements for diff systems 2023-02-04 14:19:29 -07:00
Bryan Ashby 21fb688bf6
Tidy up errors 2023-02-04 13:54:16 -07:00
Bryan Ashby b20b6cc5ca
Updates on form validation
* Better errors, using enig Errors and ErrorReasons
* If subject isn't required, don't enforce it
* Allow validator listeners to override error (ie: ignore)
2023-02-04 13:44:55 -07:00
Bryan Ashby a8e867a4bb
Support 'maxLength' property in MLTEV; Enforce this by default, to 500 characters for AP messages. WIP optional subjects, some new configuration 2023-02-04 11:51:47 -07:00
Bryan Ashby 1065f14c2e
Merge branch '459-activitypub-integration' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-02-03 15:14:54 -07:00
Nathan Byrd 00e6b41a3e Fixed focus and minor changes 2023-02-03 16:14:45 -06:00
Bryan Ashby f97d1844e3
Add attachment information to messages, fix duplicate handling to respond properly 2023-02-03 15:14:27 -07:00
Nathan Byrd 95250d23f2 Added additional fields and some cleanup 2023-02-03 15:21:19 -06:00
Bryan Ashby 5cfacf4ff0
Update WHATSNEW 2023-02-01 23:06:38 -07:00
Bryan Ashby 8a4f90263a
Split out web logging to it's own logger/files/configuration 2023-02-01 23:02:33 -07:00
Bryan Ashby d286fa2cf4
Merge branch '459-activitypub-integration' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-02-01 22:01:20 -07:00
Bryan Ashby b94fa6addd
WIP theme update 2023-02-01 22:00:45 -07:00
Bryan Ashby 5f53ef9a60
itemFormat/focusItemFormat properties in Button 2023-02-01 22:00:31 -07:00
Bryan Ashby eb9d9055e9
Allow itemFormat/focusItem format on TextViews/Buttons/... 2023-02-01 21:59:55 -07:00
Nathan Byrd 3be14ec94b Start of an actor search screen, finally 2023-02-01 21:50:32 -06:00
Nathan Byrd 1d1bf68f0d Added warning to make finding misconfigurations easier 2023-02-01 18:26:33 -06:00
Bryan Ashby 45deef3f03
Placeholder art 2023-01-31 23:13:21 -07:00
Bryan Ashby 835bfbddb0
Return to prev on save 2023-01-31 22:19:27 -07:00
Bryan Ashby f8d4f49f7f
WIP ActivityPub user config 2023-01-31 22:16:19 -07:00
Bryan Ashby 98d37e9564
Fix various Content-Lenght's 2023-01-31 20:06:07 -07:00
Bryan Ashby a829905c63
New convienience functions on toggle menus, allow paths in sys module search 2023-01-30 22:13:38 -07:00
Bryan Ashby c456c18b85
Non-dynamic info 2023-01-30 16:09:18 -07:00
Bryan Ashby 35b7c00d11
Additionl of WIP NodeInfo2 support, fix content-type for Actor images 2023-01-30 12:30:36 -07:00
Bryan Ashby 3bdce81bdb
Retro style default profile, constant cleanup, some DRY, etc. 2023-01-29 16:52:01 -07:00
Bryan Ashby 2a75d55b42
Remove alias cache never used 2023-01-28 12:59:56 -07:00
Bryan Ashby 0ca67f6729
Clean up cache, add missing FK option 2023-01-28 12:59:12 -07:00
Bryan Ashby 6dd9fe810f
Fix profile query 2023-01-28 12:13:11 -07:00
Bryan Ashby 2f577fcada
Merge branch '459-activitypub-integration' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-01-28 11:57:14 -07:00
Bryan Ashby 9b01124b2e
Re-work of ActivityPub DBs and various account lookups
* Always look up Actors by explicit Actor IDs
* Re-work DB: style, properties we track, etc.
* Create AP properties via a event!
* Lots of cleanup
* WF may be partially broken if loooking up by 'profile' alias URL: WIP
2023-01-28 11:55:31 -07:00
Nathan Byrd 4f6891a668 Fix possible BBS crash on undefined text 2023-01-27 16:27:33 -06:00
Nathan Byrd 8dd28e3091 Fixed not-found and similar errors case 2023-01-26 19:18:25 -06:00
Bryan Ashby d624871a83
Split message consts to their own file, fix some HTTP responses, better subjects from ActivityPub messages, fix AP reply indicators, ... 2023-01-26 15:42:11 -07:00
Bryan Ashby 0bd2c3db1c
Better handling of to/from HTML and BBS message formats, Note handling esp with inReplyTo, etc. 2023-01-25 22:22:45 -07:00
Bryan Ashby 4f632fd8c4
Many WebFinger improvements, can now round trip private messages 2023-01-25 18:41:47 -07:00
Bryan Ashby 82091c11c1
pretty 2023-01-24 21:53:39 -07:00
Bryan Ashby 1aa56fbaa7
WIP: Import messages sent to local Actor inboxes to their private mail 2023-01-24 21:40:12 -07:00
Nathan Byrd 5b69cdb516 Changed activity.js back for WellKnownActivityTypes 2023-01-24 20:51:40 -06:00
Nathan Byrd f8b132310c Cleaned up missing ./const 2023-01-24 19:16:14 -06:00
Bryan Ashby d5446cdb51
Cleanup and placeholder 2023-01-24 18:11:28 -07:00
Bryan Ashby d7df066ab0
Object and Note, load of public notes, etc. 2023-01-23 14:45:56 -07:00
Bryan Ashby 0fc8ae0e18
WIP on shared inbox functionality 2023-01-22 13:51:32 -07:00
Bryan Ashby 8f131630ff
Update on recording to outbox 2023-01-22 11:02:45 -07:00
Bryan Ashby d03718d55e
Move avatar handler to generic system general handler 2023-01-22 10:18:52 -07:00
Bryan Ashby 3409c99f2d
Just some basic info so far 2023-01-21 21:23:30 -07:00
Bryan Ashby c5fb1bd685
Add default avatar sprites 2023-01-21 20:58:50 -07:00
Bryan Ashby 3f2dcee5a7
Add avatar support
* Default generated for new users (oputil.js tool to come)
* Defaults to setting ActivityPub image/icon
* Allows ops to configure the look, extend, etc.
2023-01-21 20:57:22 -07:00
Bryan Ashby 468f1486c0
Use a Collection for outbox 2023-01-21 18:51:54 -07:00
Bryan Ashby ce7dd8e1cd
Handle Undo 2023-01-21 01:19:19 -07:00
Bryan Ashby d9e4b66a35
Cleanup, DRY, logging 2023-01-20 22:15:59 -07:00
Bryan Ashby 9517b292a4
Work on shifting code to generic collections, etc. 2023-01-20 16:03:27 -07:00
Bryan Ashby 930308e07f
Fix POST to Accept Follow Request 2023-01-19 22:31:14 -07:00
Bryan Ashby 51308a5ad3
Merge branch 'master' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2023-01-16 11:08:11 -07:00
Bryan Ashby b075e25330
Cleanup 2023-01-13 23:55:05 -07:00
Nathan Byrd 4841823d67 Additional merge changes 2023-01-13 23:16:55 -06:00
Nathan Byrd 3cb4f5158e Merge upstream 2023-01-13 23:00:12 -06:00
Bryan Ashby 315d77b1c0
Start Collection, some extra Actor props, start Followers, cleanup/DRY/etc. 2023-01-13 21:27:02 -07:00
Nathan Byrd c796a856b1 Bug squashing and refactored inbox 2023-01-13 16:03:09 -06:00
Nathan Byrd 84dde6c5c5 Small bugfixes 2023-01-13 13:26:12 -06:00
Nathan Byrd 1068abca80 Changed the key name for the ActivityPub signing key 2023-01-13 13:07:06 -06:00
Nathan Byrd af1f8890f6 Changed default config to include webFinger and ActivityPub 2023-01-13 12:41:06 -06:00
Bryan Ashby 9ad0cabd04
Test hook 2023-01-13 09:52:21 -07:00
Nathan Byrd 02eeee95ac Added json to list of files to update 2023-01-13 09:23:03 -06:00
Nathan Byrd 4137e935d2 Added precommit hook and staged checking of linting and formatting 2023-01-13 09:14:00 -06:00
Nathan Byrd 33ea963a14 Removed prettier so eslint output matches NuSkooler's 2023-01-13 08:45:09 -06:00
Bryan Ashby 5e5c9236ec
Return a outbox WIP 2023-01-12 23:19:52 -07:00
Bryan Ashby 157b90687c
Move ActivityPub stuff under activitypub/ 2023-01-12 18:49:13 -07:00
Bryan Ashby 67652b18f2
Prettier 2023-01-12 18:40:43 -07:00
Bryan Ashby fc14b5d299
Persist exported/published messages to ActivityPub 2023-01-12 18:38:42 -07:00
Bryan Ashby eaadd0a830
Persist exported/published messages to ActivityPub 2023-01-12 18:26:44 -07:00
Bryan Ashby 64848b4675
Initial 'Note' support for ActivitiyPub/Mastodon, reconition of @User@domain ActivityPub addresses, skeleton for ActivityPub scan/toss 2023-01-11 22:37:09 -07:00
Bryan Ashby ef118325ba
Add intiial 'flavor' for ActivityPub messages 2023-01-11 09:47:07 -07:00
Bryan Ashby 01cd91b045
Fix route 2023-01-08 20:43:16 -07:00
Bryan Ashby 44c67f5327
Added HTTP util: postJson(), post Accept Activity to server with signing 2023-01-08 20:34:30 -07:00
Bryan Ashby 3a70cc6939
Abilitiy to respond with 'Accept' Activity and ActivityPubSettings user props 2023-01-08 17:11:49 -07:00
Bryan Ashby e5b2beffcf
Actor.fromLocalUser() 2023-01-08 13:42:52 -07:00
Bryan Ashby 2370185bcc
Tidy 2023-01-08 13:26:52 -07:00
Bryan Ashby f86d9338a1
Start work on Activity and Actor objects, validation, fetching, etc. 2023-01-08 13:18:50 -07:00
Bryan Ashby 416f86a0cc
Additional notes for devs 2023-01-08 13:18:40 -07:00
Bryan Ashby f9f9208ada
Merge 2023-01-08 01:29:37 -07:00
Bryan Ashby 55b210e4e7
Steps to allow follow requests 2023-01-08 01:22:02 -07:00
Nathan Byrd 2c0992becb Added stub activitypub_actor database entries 2023-01-07 14:48:12 -06:00
Nathan Byrd 9eb3a1d37f eslint fixes as well as fixing a small variable typo 2023-01-07 13:40:52 -06:00
Bryan Ashby 23f753e4b3
Web Handlers are now given the parent Web Server directly 2023-01-07 09:50:16 -07:00
Bryan Ashby a1e54dee6d
Log cleanup 2023-01-06 18:55:24 -07:00
Bryan Ashby 60238de017
Some tidy and log cleanup 2023-01-06 18:05:11 -07:00
Nathan Byrd b252f69f05 Changes to work with Mastodon. Also fixed variable rename issue 2023-01-06 16:09:08 -06:00
Bryan Ashby d278307a81
Durp. 2023-01-06 14:19:14 -07:00
Bryan Ashby 41867c73d5
Tidy 2023-01-06 14:17:16 -07:00
Bryan Ashby 7380ef571a
Hopefully formatted correctly 2023-01-06 13:52:33 -07:00
Bryan Ashby 848044bec6
Prettier 2023-01-06 13:49:13 -07:00
Nathan Byrd d615b53f1f Small fixes to activitypub json output 2023-01-06 13:49:45 -06:00
Nathan Byrd e478665456 Added public key to selfUrl 2023-01-06 13:42:15 -06:00
Nathan Byrd 344d4716ce Added ability to use .json to get JSON from self URL. Also added some logging. 2023-01-06 13:15:29 -06:00
Nathan Byrd 9f33c8b21d Added public/private keypairs for user (and hid from logging) 2023-01-05 22:33:03 -06:00
Bryan Ashby dc7f902182
Missing import 2023-01-04 22:19:12 -07:00
Bryan Ashby 8026164ae4
Standardize on _enig prefix for internal routes; update all _internal to this prefix 2023-01-04 21:25:33 -07:00
Bryan Ashby 5055337eff
Hand back a profile for self 2023-01-04 20:29:18 -07:00
Bryan Ashby d4f74447ec
Skeleton work 2023-01-03 20:42:36 -07:00
Bryan Ashby 6cea4269b2
More utils, tidy, and activitypub.js added 2023-01-03 20:32:09 -07:00
Bryan Ashby 127e9794ee
Central check for enable/disabled web handlers, added some utility functions, etc. 2023-01-03 15:10:39 -07:00
Bryan Ashby 99e9ebbec9
Utility, cleanup, etc. 2023-01-02 22:25:32 -07:00
Bryan Ashby fb5858e90f
dev updates 2023-01-02 16:57:27 -07:00
Bryan Ashby 092acc0138
Tidy 2023-01-02 16:55:36 -07:00
Nathan Byrd bc0f7690f8 Updated ws and added http-signature 2023-01-02 17:21:53 -06:00
Nathan Byrd 395676f19d accountStatus cannot both be inactive and disabled, changed to or condition. Also removed outdated comment. 2023-01-02 15:20:24 -06:00
Bryan Ashby 25e3630458
Additional variables for profile/template 2023-01-01 21:15:43 -07:00
Bryan Ashby ff219cbb06
Filter disabled/inactive users out 2023-01-01 20:54:19 -07:00
Bryan Ashby 2b958e0885
Rough docs start 2023-01-01 20:43:15 -07:00
Bryan Ashby 380920f6c8
Profile template ability 2023-01-01 19:19:51 -07:00
Nathan Byrd db652bff59 Just added some info to the profile as an example. 2023-01-01 11:07:33 -06:00
Nathan Byrd e1b4c3e510 Refactored profile to be part of webfinger 2023-01-01 10:47:59 -06:00
Bryan Ashby 38098b46f1
Comments 2022-12-31 17:51:03 -07:00
Bryan Ashby a00a93859e
Extend from WebHandlerModule 2022-12-31 15:48:51 -07:00
Bryan Ashby 0e32e3856e
Add CognitiveGears to author 2022-12-31 15:39:54 -07:00
Bryan Ashby e78f9cdb71
Merge branch '459-activitypub-integration' of github.com:NuSkooler/enigma-bbs into 459-activitypub-integration 2022-12-31 15:31:53 -07:00
Bryan Ashby 7b5cb165ee
Updates to WebFinger resource parsing 2022-12-31 15:30:54 -07:00
Nathan Byrd fb035f2b58 Added a start at a profile 2022-12-31 16:28:03 -06:00
Bryan Ashby 3db35bc5b6
Merge pull request #461 from cognitivegears/459-activitypub-integration
Added additional fields from Masto
2022-12-31 13:02:53 -07:00
Nathan Byrd d8de6171aa Added additional fields from Masto 2022-12-31 13:05:59 -06:00
Bryan Ashby 46bc92a690
WebFinger update: return super basic info 2022-12-31 00:38:09 -07:00
Bryan Ashby b1bb66e52f
WebFinger stub 2022-12-30 22:39:39 -07:00
Bryan Ashby d2d5aad236
Add concept of 'handlers' to web server 2022-12-30 22:35:18 -07:00
474 changed files with 18424 additions and 849 deletions

View File

@ -1,24 +1,32 @@
{
"name": "Basic Node.js",
"build": { "dockerfile": "Dockerfile" },
"remoteUser": "root",
"forwardPorts": [8888, 4000],
"postCreateCommand": "gem install jekyll bundler && /bin/rm -rf node_modules && npm install && cd docs && bundle install && cd ..",
"features": {
"ghcr.io/devcontainers/features/python:1": {
"installTools": true,
"version": "3.11"
},
"ghcr.io/devcontainers-contrib/features/curl-apt-get:1": {},
"ghcr.io/jungaretti/features/ripgrep:1": {},
"ghcr.io/warrenbuckley/codespace-features/sqlite:1": {},
"ghcr.io/devcontainers/features/ruby:1": {
"version": "3.1"
}
},
"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"]
}
}
"name": "Basic Node.js",
"build": { "dockerfile": "Dockerfile" },
"remoteUser": "root",
"forwardPorts": [8888, 4000],
"postCreateCommand": "gem install jekyll bundler && /bin/rm -rf node_modules && npm install && cd docs && bundle install && cd ..",
"features": {
"ghcr.io/devcontainers/features/python:1": {
"installTools": true,
"version": "3.11"
},
"ghcr.io/devcontainers-contrib/features/curl-apt-get:1": {},
"ghcr.io/jungaretti/features/ripgrep:1": {},
"ghcr.io/warrenbuckley/codespace-features/sqlite:1": {},
"ghcr.io/devcontainers/features/ruby:1": {
"version": "3.1"
}
},
"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"
]
}
}
}

View File

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

View File

@ -11,14 +11,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
@ -31,5 +31,5 @@ jobs:
with:
tags: enigmabbs/enigma-bbs:latest
file: docker/Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

4
.lintstagedrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"*.js": ["npx eslint --fix", "npx prettier --write"],
"*.json": ["npx eslint --fix", "npx prettier --write"]
}

View File

@ -1,6 +1,3 @@
{
"recommendations": [
"ms-vscode-remote.remote-containers",
"laktak.hjson"
]
"recommendations": ["ms-vscode-remote.remote-containers", "laktak.hjson"]
}

View File

@ -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');
```

View File

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

View File

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

View File

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

View File

@ -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'
@ -52,7 +62,7 @@ npm install # or simply 'yarn'
// position reports, which are not supported on all terminals.
// Using this with a terminal that does not support cursor
// position reports results in a 2 second delay during the
// connect process, but provides better autoconfiguration of utf-8
// connect process, but provides better auto configuration of utf-8
checkUtf8Encoding: true

View File

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

View File

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

View File

@ -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();
},

View File

@ -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');
}
};

313
core/activitypub/actor.js Normal file
View File

@ -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);
});
});
}
};

View File

@ -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);
});
}
};

View File

@ -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,
};
}
};

46
core/activitypub/const.js Normal file
View File

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

View File

@ -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}`);
}

View File

@ -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);
}
);
}

433
core/activitypub/note.js Normal file
View File

@ -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';
}
};

115
core/activitypub/object.js Normal file
View File

@ -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);
}
};

View File

@ -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);
});
}
);
}
};

View File

@ -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);
}
);
});
}
};

View File

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

305
core/activitypub/util.js Normal file
View File

@ -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);
});
});
}

View File

@ -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');
@ -82,15 +82,14 @@ function ANSIEscapeParser(options) {
};
self.positionUpdated = function () {
if(self.row > self.termHeight) {
if(this.savedPosition) {
if (self.row > self.termHeight) {
if (this.savedPosition) {
this.savedPosition.row -= self.row - self.termHeight;
}
self.emit('scroll', self.row - self.termHeight);
self.row = self.termHeight;
}
else if(self.row < 1) {
if(this.savedPosition) {
} else if (self.row < 1) {
if (this.savedPosition) {
this.savedPosition.row -= self.row - 1;
}
self.emit('scroll', -(self.row - 1));
@ -140,7 +139,7 @@ function ANSIEscapeParser(options) {
start = pos + 1;
// If we hit breakWidth before termWidth then we need to force the terminal to go to the next line.
if(self.column < self.termWidth) {
if (self.column < self.termWidth) {
self.emit('literal', '\r\n');
}
self.column = 1;
@ -287,11 +286,11 @@ 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
switch(opCode) {
switch (opCode) {
// save cursor position
case '7':
escape('s', args);
@ -322,8 +321,7 @@ function ANSIEscapeParser(options) {
escape('T', args);
break;
}
}
else {
} else {
escape(opCode, args);
}
@ -396,11 +394,10 @@ function ANSIEscapeParser(options) {
// line feed
case 'E':
arg = isNaN(args[0]) ? 1 : args[0];
if(this.row + arg > this.termHeight) {
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;
@ -408,11 +405,10 @@ function ANSIEscapeParser(options) {
// reverse line feed
case 'F':
arg = isNaN(args[0]) ? 1 : args[0];
if(this.row - arg < 1) {
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,29 +430,24 @@ function ANSIEscapeParser(options) {
self.positionUpdated();
break;
// erase display/screen
case 'J':
if(isNaN(args[0]) || 0 === args[0]) {
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;
// erase text in line
case 'K':
if(isNaN(args[0]) || 0 === args[0]) {
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;
@ -573,7 +564,7 @@ function ANSIEscapeParser(options) {
// back tab
case 'Z':
// calculate previous tabstop
self.column = Math.max( 1, self.column - (self.column % 8 || 8) );
self.column = Math.max(1, self.column - (self.column % 8 || 8));
self.positionUpdated();
break;
case '@':
@ -581,7 +572,6 @@ function ANSIEscapeParser(options) {
arg = isNaN(args[0]) ? 1 : args[0];
self.emit('insert columns', self.row, self.column, arg);
break;
}
}
}

View File

@ -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}`
)
);
}

View File

@ -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;
}
}
@ -342,7 +342,7 @@ function display(client, art, options, cb) {
_.forEach(mciMap, (mciInfo, mapKey) => {
if (mciInfo.position[0] === row && mciInfo.position[1] >= startCol) {
mciInfo.position[1] += numCols;
if(mciInfo.position[1] > client.term.termWidth) {
if (mciInfo.position[1] > client.term.termWidth) {
delete mciMap[mapKey];
}
}
@ -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;
}
@ -373,12 +373,11 @@ function display(client, art, options, cb) {
ansiParser.on('delete line', (row, numLines) => {
_.forEach(mciMap, (mciInfo, mapKey) => {
if (mciInfo.position[0] >= row) {
if(mciInfo.position[0] < row + numLines) {
if (mciInfo.position[0] < row + numLines) {
// 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;
}
}

View File

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

View File

@ -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);
},
//

View File

@ -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);
};

View File

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

View File

@ -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`);
}

View File

@ -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);
@ -312,22 +312,22 @@ const DB_INIT_TABLE = {
// :TODO: need SQL to ensure cleaned up if delete from message?
/*
dbs.message.run(
`CREATE TABLE IF NOT EXISTS hash_tag (
hash_tag_id INTEGER PRIMARY KEY,
hash_tag_name VARCHAR NOT NULL,
UNIQUE(hash_tag_name)
);`
);
dbs.message.run(
`CREATE TABLE IF NOT EXISTS hash_tag (
hash_tag_id INTEGER PRIMARY KEY,
hash_tag_name VARCHAR NOT NULL,
UNIQUE(hash_tag_name)
);`
);
// :TODO: need SQL to ensure cleaned up if delete from message?
dbs.message.run(
`CREATE TABLE IF NOT EXISTS message_hash_tag (
hash_tag_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
);`
);
*/
// :TODO: need SQL to ensure cleaned up if delete from message?
dbs.message.run(
`CREATE TABLE IF NOT EXISTS message_hash_tag (
hash_tag_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
);`
);
*/
dbs.message.run(
`CREATE TABLE IF NOT EXISTS user_message_area_last_read (
@ -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);
}
);
}

View File

@ -58,7 +58,7 @@ module.exports = class Door {
this.encoding = (exeInfo.encoding || 'cp437').toLowerCase();
if ('socket' === this.io) {
if(!this.sockServer) {
if (!this.sockServer) {
return cb(Errors.UnexpectedState('Socket server is not running'));
}
} else if ('stdio' !== this.io) {
@ -116,7 +116,7 @@ module.exports = class Door {
);
prePty.onExit(exitEvent => {
const {exitCode, signal} = exitEvent;
const { exitCode, signal } = exitEvent;
this.client.log.info(
{ exitCode, signal },
'Door pre-command exited'
@ -168,7 +168,7 @@ module.exports = class Door {
this.doorPty.onData(this.doorDataHandler.bind(this));
this.doorPty.onExit( (/*exitEvent*/) => {
this.doorPty.onExit((/*exitEvent*/) => {
return this.restoreIo(this.doorPty);
});
} else if ('socket' === this.io) {
@ -182,7 +182,7 @@ module.exports = class Door {
}
this.doorPty.onExit(exitEvent => {
const {exitCode, signal} = exitEvent;
const { exitCode, signal } = exitEvent;
this.client.log.info({ exitCode, signal }, 'Door exited');
if (this.sockServer) {

View File

@ -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',
};

View File

@ -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'
);
}

View File

@ -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);
},
};
}

View File

@ -344,7 +344,7 @@ exports.getModule = class FileAreaList extends MenuModule {
);
}
displayArtDataPrepCallback(name, artData, viewController) {
displayArtDataPrepCallback(name, artData) {
if (name === 'details') {
try {
this.detailsInfoArea = {

View File

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

View File

@ -36,7 +36,7 @@ exports.getModule = class FileAreaSelectModule extends MenuModule {
extraArgs: {
filterCriteria: filterCriteria,
},
menuFlags: [ MenuFlags.NoHistory ],
menuFlags: [MenuFlags.NoHistory],
};
return this.gotoMenu(

View File

@ -12,7 +12,6 @@ const Config = require('./config.js').get;
// deps
const async = require('async');
const _ = require('lodash');
const moment = require('moment');
exports.moduleInfo = {

View File

@ -486,7 +486,7 @@ exports.getModule = class TransferFileModule extends MenuModule {
});
externalProc.onExit(exitEvent => {
const {exitCode, signal} = exitEvent;
const { exitCode, signal } = exitEvent;
this.client.log.debug(
{ cmd: cmd, args: args, exitCode, signal },
'Process exited'

View File

@ -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,10 +457,15 @@ exports.FullScreenEditorModule =
//
// Append auto-signature, if enabled for the area & the user has one
//
if (false != area.autoSignatures) {
const sig = this.client.user.getProperty(UserProps.AutoSignature);
if (sig) {
messageBody += `\r\n-- \r\n${sig}`;
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}`;
}
}
}
@ -459,106 +493,108 @@ exports.FullScreenEditorModule =
this.message = message;
this.updateLastReadId(() => {
if (this.isReady) {
this.initHeaderViewMode();
this.initFooterViewMode();
if (!this.isReady) {
return;
}
const bodyMessageView = this.viewControllers.body.getView(
MciViewIds.body.message
);
let msg = this.message.message;
this.initHeaderViewMode();
this.initFooterViewMode();
if (bodyMessageView && _.has(this, 'message.message')) {
const bodyMessageView = this.viewControllers.body.getView(
MciViewIds.body.message
);
let msg = this.message.message;
if (bodyMessageView && _.has(this, 'message.message')) {
//
// We handle ANSI messages differently than standard messages -- this is required as
// we don't want to do things like word wrap ANSI, but instead, trust that it's formatted
// how the author wanted it
//
if (isAnsi(msg)) {
//
// We handle ANSI messages differently than standard messages -- this is required as
// we don't want to do things like word wrap ANSI, but instead, trust that it's formatted
// how the author wanted it
// Find tearline - we want to color it differently.
//
if (isAnsi(msg)) {
//
// Find tearline - we want to color it differently.
//
const tearLinePos = Message.getTearLinePosition(msg);
const tearLinePos = Message.getTearLinePosition(msg);
if (tearLinePos > -1) {
msg = insert(
msg,
tearLinePos,
bodyMessageView.getSGRFor('text')
);
if (tearLinePos > -1) {
msg = insert(
msg,
tearLinePos,
bodyMessageView.getTextSgrPrefix()
);
}
bodyMessageView.setAnsi(
msg.replace(/\r?\n/g, '\r\n'), // messages are stored with CRLF -> LF
{
prepped: false,
forceLineTerm: true,
}
);
} else {
msg = stripAnsiControlCodes(msg); // start clean
bodyMessageView.setAnsi(
msg.replace(/\r?\n/g, '\r\n'), // messages are stored with CRLF -> LF
{
prepped: false,
forceLineTerm: true,
const styleToArray = (style, len) => {
if (!Array.isArray(style)) {
style = [style];
}
while (style.length < len) {
style.push(style[0]);
}
return style;
};
//
// In *View* mode, if enabled, do a little prep work so we can stylize:
// - Quote indicators
// - Tear lines
// - Origins
//
if (this.menuConfig.config.quoteStyleLevel1) {
// can be a single style to cover 'XX> TEXT' or an array to cover 'XX', '>', and TEXT
// Non-standard (as for BBSes) single > TEXT, omitting space before XX, etc. are allowed
const styleL1 = styleToArray(
this.menuConfig.config.quoteStyleLevel1,
3
);
const QuoteRegex =
/^([ ]?)([!-~]{0,2})>([ ]*)([^\r\n]*\r?\n)/gm;
msg = msg.replace(
QuoteRegex,
(m, spc1, initials, spc2, text) => {
return `${spc1}${styleL1[0]}${initials}${styleL1[1]}>${spc2}${styleL1[2]}${text}${bodyMessageView.styleSGR1}`;
}
);
} else {
msg = stripAnsiControlCodes(msg); // start clean
const styleToArray = (style, len) => {
if (!Array.isArray(style)) {
style = [style];
}
while (style.length < len) {
style.push(style[0]);
}
return style;
};
//
// In *View* mode, if enabled, do a little prep work so we can stylize:
// - Quote indicators
// - Tear lines
// - Origins
//
if (this.menuConfig.config.quoteStyleLevel1) {
// can be a single style to cover 'XX> TEXT' or an array to cover 'XX', '>', and TEXT
// Non-standard (as for BBSes) single > TEXT, omitting space before XX, etc. are allowed
const styleL1 = styleToArray(
this.menuConfig.config.quoteStyleLevel1,
3
);
const QuoteRegex =
/^([ ]?)([!-~]{0,2})>([ ]*)([^\r\n]*\r?\n)/gm;
msg = msg.replace(
QuoteRegex,
(m, spc1, initials, spc2, text) => {
return `${spc1}${styleL1[0]}${initials}${styleL1[1]}>${spc2}${styleL1[2]}${text}${bodyMessageView.styleSGR1}`;
}
);
}
if (this.menuConfig.config.tearLineStyle) {
// '---' and TEXT
const style = styleToArray(
this.menuConfig.config.tearLineStyle,
2
);
const TearLineRegex = /^--- (.+)$(?![\s\S]*^--- .+$)/m;
msg = msg.replace(TearLineRegex, (m, text) => {
return `${style[0]}--- ${style[1]}${text}${bodyMessageView.styleSGR1}`;
});
}
if (this.menuConfig.config.originStyle) {
const style = styleToArray(
this.menuConfig.config.originStyle,
3
);
const OriginRegex = /^([ ]{1,2})\* Origin: (.+)$/m;
msg = msg.replace(OriginRegex, (m, spc, text) => {
return `${spc}${style[0]}* ${style[1]}Origin: ${style[2]}${text}${bodyMessageView.styleSGR1}`;
});
}
bodyMessageView.setText(controlCodesToAnsi(msg));
}
if (this.menuConfig.config.tearLineStyle) {
// '---' and TEXT
const style = styleToArray(
this.menuConfig.config.tearLineStyle,
2
);
const TearLineRegex = /^--- (.+)$(?![\s\S]*^--- .+$)/m;
msg = msg.replace(TearLineRegex, (m, text) => {
return `${style[0]}--- ${style[1]}${text}${bodyMessageView.styleSGR1}`;
});
}
if (this.menuConfig.config.originStyle) {
const style = styleToArray(
this.menuConfig.config.originStyle,
3
);
const OriginRegex = /^([ ]{1,2})\* Origin: (.+)$/m;
msg = msg.replace(OriginRegex, (m, spc, text) => {
return `${spc}${style[0]}* ${style[1]}Origin: ${style[2]}${text}${bodyMessageView.styleSGR1}`;
});
}
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,26 +647,31 @@ 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) {
return callback(err);
if (self.message.isPrivate()) {
return callback(err);
}
if (areaInfo.addressFlavor) {
self.message.setExternalFlavor(
areaInfo.addressFlavor
);
}
return callback(null);
}
self.message.setLocalToUserId(toUserId);
@ -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`);
}

View File

@ -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,14 +282,29 @@ 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) {
for (
let i = this.pages[this.currentPage].start;
i <= this.pages[this.currentPage].end;
++i
if (
this.currentPage > this.pages.length ||
!_.isObject(this.pages[this.currentPage])
) {
this.items[i].focused = this.focusedItemIndex === i;
this.drawItem(i);
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;
++i
) {
this.items[i].focused = this.focusedItemIndex === i;
this.drawItem(i);
}
}
}
};
@ -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;
@ -378,10 +409,20 @@ FullMenuView.prototype.focusNext = function () {
this.focusedItemIndex = 0;
this.currentPage = 0;
} else {
this.focusedItemIndex++;
if (this.focusedItemIndex > this.pages[this.currentPage].end) {
this.clearPage();
this.currentPage++;
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) {
this.clearPage();
this.currentPage++;
}
}
}
@ -397,9 +438,19 @@ FullMenuView.prototype.focusPrevious = function () {
this.currentPage = this.pages.length - 1;
} else {
this.focusedItemIndex--;
if (this.focusedItemIndex < this.pages[this.currentPage].start) {
this.clearPage();
this.currentPage--;
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--;
}
}
}

132
core/http_util.js Normal file
View File

@ -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();
}

View File

@ -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);
}
async.series(
[
callback => {
return moduleInst.createServer(callback);
},
callback => {
listeningServers[module.moduleInfo.packageName] = {
instance: moduleInst,
info: module.moduleInfo,
};
moduleInst.listen(err => {
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);
}
listeningServers[module.moduleInfo.packageName] = {
instance: moduleInst,
info: module.moduleInfo,
};
return nextModule(null);
});
});
}
);
} catch (e) {
logger.log.error(e, 'Exception caught creating server!');
return nextModule(e);

View File

@ -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}":"********"`;
}
)
);

View File

@ -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 '';
}

View File

@ -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) {
view.addText(text);
if (view instanceof MultiLineEditTextView) {
if (options.appendMultiLine) {
view.addText(text);
} else {
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) {
if (view.getData() !== text) {
view.setText(text);
} else {
view.redraw();

View File

@ -48,13 +48,13 @@ module.exports = class MenuStack {
return cb(
Array.isArray(menuConfig.next)
? Errors.MenuStack(
'No matching condition for "next"',
ErrorReasons.NoConditionMatch
)
'No matching condition for "next"',
ErrorReasons.NoConditionMatch
)
: Errors.MenuStack(
'Invalid or missing "next" member in menu config',
ErrorReasons.InvalidNextMenu
)
'Invalid or missing "next" member in menu config',
ErrorReasons.InvalidNextMenu
)
);
}
@ -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();

View File

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

View File

@ -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,18 +834,69 @@ 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) => {
trimMessageAreaByMaxMessages(areaInfo, err => {
if (err) {
return next(err);
(areaInfo, nextArea) => {
async.series(
[
next => {
trimMessageAreaByMaxMessages(areaInfo, 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 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);
}
trimMessageAreaByMaxAgeDays(areaInfo, err => {
return next(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
);

106
core/message_const.js Normal file
View File

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

View File

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

View File

@ -52,7 +52,7 @@ exports.getModule = class MessageAreaListModule extends MenuModule {
extraArgs: {
areaTag: area.areaTag,
},
menuFlags: [ MenuFlags.NoHistory ],
menuFlags: [MenuFlags.NoHistory],
};
return this.gotoMenu(

View File

@ -52,7 +52,7 @@ exports.getModule = class MessageConfListModule extends MenuModule {
extraArgs: {
confTag: conf.confTag,
},
menuFlags: [ MenuFlags.NoHistory ],
menuFlags: [MenuFlags.NoHistory],
};
return this.gotoMenu(

View File

@ -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();

View File

@ -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)) {
text += eolMarker;
}
});
return text;
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);

View File

@ -2,9 +2,8 @@
'use strict';
// ENiGMA½
const { MenuModule, MenuFlags } = require('./menu_module');
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'
);
}

View File

@ -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);
}

View File

@ -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);
},
//

149
core/oputil/activitypub.js Normal file
View File

@ -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);
}

View File

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

View File

@ -250,6 +250,7 @@ function buildNewConfig() {
'new_user.in.hjson',
'doors.in.hjson',
'file_base.in.hjson',
'activitypub.in.hjson',
];
let includeFiles = [];

View File

@ -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
`,
});

View File

@ -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);
}

View File

@ -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,27 +379,35 @@ 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) {
const otp = user.getProperty(UserProps.AuthFactor2OTP);
if (otp) {
const backupCodesOrNa = () => {
try {
return JSON.parse(
user.getProperty(UserProps.AuthFactor2OTPBackupCodes)
).join(', ');
} catch (e) {
return 'N/A';
}
};
secInfo = `\n2FA OTP : ${otp}
OTP secret : ${user.getProperty(UserProps.AuthFactor2OTPSecret) || 'N/A'}
OTP Backup : ${backupCodesOrNa()}`;
Affiliations : ${propOrNA(UserProps.Affiliations)}
ActivityPub : ${apSettings.enabled ? 'enabled' : 'disabled'}`;
const otp = user.getProperty(UserProps.AuthFactor2OTP);
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(
user.getProperty(UserProps.AuthFactor2OTPBackupCodes)
).join(', ');
} catch (e) {
return 'N/A';
}
};
infoDump += `\nOTP secret : ${
user.getProperty(UserProps.AuthFactor2OTPSecret) || 'N/A'
}
OTP Backup : ${backupCodesOrNa()}`;
}
console.info(`${stdInfo}${secInfo}`);
console.info(infoDump);
}
function twoFactorAuthOTP(user) {

View File

@ -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();

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
}
);
}
};

View File

@ -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);
});
}
};

View File

@ -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];
}
}
}
};

View File

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

View File

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

View File

@ -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', // { ... }

View File

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

View File

@ -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;
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);
}
let textToDraw = _.isString(this.textMaskChar)
? new Array(renderLength + 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();

View File

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

View File

@ -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);
},
};
}

View File

@ -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);
}
);
}

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More