├── .djlintrc
├── .dockerignore
├── .env.example
├── .eslintrc.cjs
├── .github
└── workflows
│ └── lint.yml
├── .gitignore
├── .husky
└── pre-commit
├── .prettierrc
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Caddyfile
├── Dockerfile
├── LICENSE.md
├── README.md
├── design
├── dashboard.handlebars
├── dms.handlebars
├── feeds.handlebars
├── findresults.handlebars
├── followers.handlebars
├── following.handlebars
├── layouts
│ ├── activity.handlebars
│ ├── private.handlebars
│ └── public.handlebars
├── notifications.handlebars
├── partials
│ ├── avatar.handlebars
│ ├── byline.handlebars
│ ├── composer.handlebars
│ ├── dm.handlebars
│ ├── feeds.handlebars
│ ├── minicomposer.handlebars
│ ├── note.handlebars
│ ├── peopleTools.handlebars
│ ├── personCard.handlebars
│ └── profileHeader.handlebars
├── prefs.handlebars
└── public
│ ├── home.handlebars
│ └── note.handlebars
├── index.js
├── lib
├── ActivityPub.js
├── Markdown.js
├── account.js
├── notes.js
├── prefs.js
├── queue.js
├── storage.js
├── theAlgorithm.js
└── users.js
├── package-lock.json
├── package.json
├── public
├── .DS_Store
├── app.js
├── css
│ ├── main.css
│ └── secret.css
└── images
│ ├── apple-touch-icon.png
│ ├── avatar-unknown.png
│ ├── avatar.png
│ └── header.png
└── routes
├── account.js
├── admin.js
├── inbox.js
├── index.js
├── notes.js
├── outbox.js
├── public.js
└── webfinger.js
/.djlintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extension": "handlebars",
3 | "profile": "handlebars",
4 | "indent": 2,
5 | "format_css": true,
6 | "css": {
7 | "indent_size": 2
8 | }
9 | }
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .djlintrc
2 | .env
3 | .env.*
4 | .eslintrc.cjs
5 | .github
6 | .husky
7 | .prettierrc
8 | CHANGELOG.md
9 | CONTRIBUTING.md
10 | LICENSE.md
11 | README.md
12 | node_modules
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | USERNAME=
2 | PASS=
3 | DOMAIN=
4 | PORT=
5 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | node: true
6 | },
7 | extends: ['standard', 'prettier'],
8 | overrides: [
9 | ],
10 | parserOptions: {
11 | ecmaVersion: 'latest',
12 | sourceType: 'module'
13 | },
14 | rules: {
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Validate code style
2 | on:
3 | pull_request:
4 | branches:
5 | - main
6 | types:
7 | - opened
8 | - synchronize
9 | - reopened
10 | concurrency:
11 | group: style_${{ github.event.pull_request.number }}
12 | cancel-in-progress: true
13 | jobs:
14 | style:
15 | name: Code style validations
16 | runs-on: ubuntu-latest
17 | permissions:
18 | id-token: write
19 | contents: read
20 | pull-requests: write
21 | checks: write
22 | steps:
23 | - name: Checkout repo
24 | uses: 'actions/checkout@v3'
25 |
26 | - name: Configure Node.js
27 | uses: actions/setup-node@v3
28 | with:
29 | node-version: 16
30 |
31 | - name: Retrieve npm cache
32 | uses: 'actions/cache@v3'
33 | with:
34 | path: ${{ github.workspace }}/node_modules
35 | key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
36 | restore-keys: |
37 | ${{ runner.os }}-node-
38 |
39 | - name: Install Node packages
40 | run: npm ci
41 | - name: Get changed files
42 | id: changes
43 | run: |
44 | echo "::set-output name=files::$(git diff --name-only --diff-filter=ACMRT ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | grep .handlebars$ | xargs)"
45 |
46 | - uses: EPMatt/reviewdog-action-prettier@v1
47 | with:
48 | github_token: ${{ secrets.github_token }}
49 | reporter: github-pr-check
50 | level: warning
51 |
52 | - uses: reviewdog/action-eslint@v1
53 | with:
54 | reporter: github-pr-check
55 | eslint_flags: '${{ github.workspace }}/lib/**/*.js ${{ github.workspace }}/routes/**/*.js ${{ github.workspace }}/public/**/*.js'
56 | # test
57 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .data
2 |
3 | # Created by https://www.toptal.com/developers/gitignore/api/macos,linux,windows,webstorm,yarn,diff,snyk,node,tower
4 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,linux,windows,webstorm,yarn,diff,snyk,node,tower
5 |
6 | ### Diff ###
7 | *.patch
8 | *.diff
9 |
10 | ### Linux ###
11 | *~
12 |
13 | # temporary files which can be created if a process still has a handle open of a deleted file
14 | .fuse_hidden*
15 |
16 | # KDE directory preferences
17 | .directory
18 |
19 | # Linux trash folder which might appear on any partition or disk
20 | .Trash-*
21 |
22 | # .nfs files are created when an open file is removed but is still being accessed
23 | .nfs*
24 |
25 | ### macOS ###
26 | # General
27 | .DS_Store
28 | .AppleDouble
29 | .LSOverride
30 |
31 | # Icon must end with two \r
32 | Icon
33 |
34 |
35 | # Thumbnails
36 | ._*
37 |
38 | # Files that might appear in the root of a volume
39 | .DocumentRevisions-V100
40 | .fseventsd
41 | .Spotlight-V100
42 | .TemporaryItems
43 | .Trashes
44 | .VolumeIcon.icns
45 | .com.apple.timemachine.donotpresent
46 |
47 | # Directories potentially created on remote AFP share
48 | .AppleDB
49 | .AppleDesktop
50 | Network Trash Folder
51 | Temporary Items
52 | .apdisk
53 |
54 | ### macOS Patch ###
55 | # iCloud generated files
56 | *.icloud
57 |
58 | ### Node ###
59 | # Logs
60 | logs
61 | *.log
62 | npm-debug.log*
63 | yarn-debug.log*
64 | yarn-error.log*
65 | lerna-debug.log*
66 | .pnpm-debug.log*
67 |
68 | # Diagnostic reports (https://nodejs.org/api/report.html)
69 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
70 |
71 | # Runtime data
72 | pids
73 | *.pid
74 | *.seed
75 | *.pid.lock
76 |
77 | # Directory for instrumented libs generated by jscoverage/JSCover
78 | lib-cov
79 |
80 | # Coverage directory used by tools like istanbul
81 | coverage
82 | *.lcov
83 |
84 | # nyc test coverage
85 | .nyc_output
86 |
87 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
88 | .grunt
89 |
90 | # Bower dependency directory (https://bower.io/)
91 | bower_components
92 |
93 | # node-waf configuration
94 | .lock-wscript
95 |
96 | # Compiled binary addons (https://nodejs.org/api/addons.html)
97 | build/Release
98 |
99 | # Dependency directories
100 | node_modules/
101 | jspm_packages/
102 |
103 | # Snowpack dependency directory (https://snowpack.dev/)
104 | web_modules/
105 |
106 | # TypeScript cache
107 | *.tsbuildinfo
108 |
109 | # Optional npm cache directory
110 | .npm
111 |
112 | # Optional eslint cache
113 | .eslintcache
114 |
115 | # Optional stylelint cache
116 | .stylelintcache
117 |
118 | # Microbundle cache
119 | .rpt2_cache/
120 | .rts2_cache_cjs/
121 | .rts2_cache_es/
122 | .rts2_cache_umd/
123 |
124 | # Optional REPL history
125 | .node_repl_history
126 |
127 | # Output of 'npm pack'
128 | *.tgz
129 |
130 | # Yarn Integrity file
131 | .yarn-integrity
132 |
133 | # dotenv environment variable files
134 | .env
135 | .env.development.local
136 | .env.test.local
137 | .env.production.local
138 | .env.local
139 |
140 | # parcel-bundler cache (https://parceljs.org/)
141 | .cache
142 | .parcel-cache
143 |
144 | # Next.js build output
145 | .next
146 | out
147 |
148 | # Nuxt.js build / generate output
149 | .nuxt
150 | dist
151 |
152 | # Gatsby files
153 | .cache/
154 | # Comment in the public line in if your project uses Gatsby and not Next.js
155 | # https://nextjs.org/blog/next-9-1#public-directory-support
156 | # public
157 |
158 | # vuepress build output
159 | .vuepress/dist
160 |
161 | # vuepress v2.x temp and cache directory
162 | .temp
163 |
164 | # Docusaurus cache and generated files
165 | .docusaurus
166 |
167 | # Serverless directories
168 | .serverless/
169 |
170 | # FuseBox cache
171 | .fusebox/
172 |
173 | # DynamoDB Local files
174 | .dynamodb/
175 |
176 | # TernJS port file
177 | .tern-port
178 |
179 | # Stores VSCode versions used for testing VSCode extensions
180 | .vscode-test
181 |
182 | # yarn v2
183 | .yarn/cache
184 | .yarn/unplugged
185 | .yarn/build-state.yml
186 | .yarn/install-state.gz
187 | .pnp.*
188 |
189 | ### Node Patch ###
190 | # Serverless Webpack directories
191 | .webpack/
192 |
193 | # Optional stylelint cache
194 |
195 | # SvelteKit build / generate output
196 | .svelte-kit
197 |
198 | ### Snyk ###
199 | # DeepCode
200 | .dccache
201 |
202 | ### Tower ###
203 | # Tower.app - http://www.git-tower.com/
204 | Icon.png
205 |
206 | ### WebStorm ###
207 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
208 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
209 | .idea
210 |
211 | # CMake
212 | cmake-build-*/
213 |
214 | # Mongo Explorer plugin
215 | .idea/**/mongoSettings.xml
216 |
217 | # File-based project format
218 | *.iws
219 |
220 | # IntelliJ
221 | out/
222 |
223 | # mpeltonen/sbt-idea plugin
224 | .idea_modules/
225 |
226 | # JIRA plugin
227 | atlassian-ide-plugin.xml
228 |
229 | # Cursive Clojure plugin
230 | .idea/replstate.xml
231 |
232 | # SonarLint plugin
233 | .idea/sonarlint/
234 |
235 | # Crashlytics plugin (for Android Studio and IntelliJ)
236 | com_crashlytics_export_strings.xml
237 | crashlytics.properties
238 | crashlytics-build.properties
239 | fabric.properties
240 |
241 | # Editor-based Rest Client
242 | .idea/httpRequests
243 |
244 | # Android studio 3.1+ serialized cache file
245 | .idea/caches/build_file_checksums.ser
246 |
247 | ### WebStorm Patch ###
248 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
249 |
250 | # *.iml
251 | # modules.xml
252 | # .idea/misc.xml
253 | # *.ipr
254 |
255 | # Sonarlint plugin
256 | # https://plugins.jetbrains.com/plugin/7973-sonarlint
257 | .idea/**/sonarlint/
258 |
259 | # SonarQube Plugin
260 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
261 | .idea/**/sonarIssues.xml
262 |
263 | # Markdown Navigator plugin
264 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
265 | .idea/**/markdown-navigator.xml
266 | .idea/**/markdown-navigator-enh.xml
267 | .idea/**/markdown-navigator/
268 |
269 | # Cache file creation bug
270 | # See https://youtrack.jetbrains.com/issue/JBR-2257
271 | .idea/$CACHE_FILE$
272 |
273 | # CodeStream plugin
274 | # https://plugins.jetbrains.com/plugin/12206-codestream
275 | .idea/codestream.xml
276 |
277 | # Azure Toolkit for IntelliJ plugin
278 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
279 | .idea/**/azureSettings.xml
280 |
281 | ### Windows ###
282 | # Windows thumbnail cache files
283 | Thumbs.db
284 | Thumbs.db:encryptable
285 | ehthumbs.db
286 | ehthumbs_vista.db
287 |
288 | # Dump file
289 | *.stackdump
290 |
291 | # Folder config file
292 | [Dd]esktop.ini
293 |
294 | # Recycle Bin used on file shares
295 | $RECYCLE.BIN/
296 |
297 | # Windows Installer files
298 | *.cab
299 | *.msi
300 | *.msix
301 | *.msm
302 | *.msp
303 |
304 | # Windows shortcuts
305 | *.lnk
306 |
307 | ### yarn ###
308 | # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
309 |
310 | .yarn/*
311 | !.yarn/releases
312 | !.yarn/patches
313 | !.yarn/plugins
314 | !.yarn/sdks
315 | !.yarn/versions
316 |
317 | # if you are NOT using Zero-installs, then:
318 | # comment the following lines
319 | !.yarn/cache
320 |
321 | # and uncomment the following lines
322 | # .pnp.*
323 |
324 | # End of https://www.toptal.com/developers/gitignore/api/macos,linux,windows,webstorm,yarn,diff,snyk,node,tower
325 |
326 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | PATH=$PATH:./node_modules/.bin
3 | . "$(dirname -- "$0")/_/husky.sh"
4 | lint-staged
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "trailingComma": "none",
8 | "bracketSpacing": true,
9 | "arrowParens": "avoid",
10 | "requirePragma": false,
11 | "insertPragma": false,
12 | "proseWrap": "preserve"
13 | }
14 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # How to update
2 |
3 | For now, the best way to upgrade is to pull the latest code from the main branch.
4 | (One day I'll set up proper packaging with versions and stuff. That day is not today!)
5 |
6 | * Type `git pull origin main`
7 | * Make sure to restart the app afterwards
8 |
9 | # CHANGE LOG
10 |
11 | ## 2023-01-30
12 | - Fixed the missing edit button
13 | - Fixed a bug where people without a `name` field would show up blank in some places
14 | - Fixed a bug related to missing environment variables
15 |
16 | ## 2023-01-29
17 | - Introduced a background queue system for handling outbound HTTP requests.
18 | - Fixed follows - outbound follow requests got disabled on 01/23! Oops!
19 | - Fixed communication with Pixelfed and GoToSocial instances. Follows and stuff should now work!
20 |
21 | ## 2023-01-23
22 | - Added linting and code prettification as a pre-commit hook
23 | - Added Github actions to run linter rules and enforce them on PRs
24 | Huge thanks to @selfagency for these improvements!
25 |
26 | ## 2023-01-15
27 | - Added a prefs page
28 | - Added the ability to change all the emojis in the UI 👹
29 | - Added the ability to change what it says on the "post" button
30 | See a video of these features in action here: https://www.loom.com/share/c8fbe3b099f644d596cd2db26e86bc8a
31 |
32 |
33 | ## 2023-01-14
34 | - All new nav! There is now a list of the 20 most recently updated feeds in the nav. Click "..." to see up to 100.
35 | - Lots of CSS improvements!
36 | - When you search for a user, Shuttlecraft will now also search all known users
37 |
38 |
39 | ## 2023-01-12
40 | - Prevent buttons from being double-clicked resulting in accidentally undoing something or double posting
41 | - Prevent the account.json file from being created with a faulty domain name. Thanks @patrickmcurry!
42 |
43 |
44 | ## 2023-01-09
45 | - Fixed a bug causing new posts not to show up til the server restarts. Oops!
46 |
47 | ## 2023-01-08
48 | - Added support for incoming DELETE activities. This causes matching posts to be completely removed from the system. As part of this, increased resilience for dealing with missing or unreachable posts. Thanks to @ringtailsoftware.
49 | - Added support for editing local posts. Thanks to @ringtailsoftware.
50 | - Renamed the sample .env to .env.example and introduced a post-install script to copy it into place
51 | - Created this changelog!
52 |
53 | ## 2023-01-07
54 | - Links in posts now automatically include noopen noreferer nofollow attributes.
55 |
56 | ## 2023-01-06
57 | - Support for light/dark themes. Thanks @anildash
58 | - Fix pagination bugs, add pagination on notifications
59 |
60 |
61 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | Contributions of any kind are welcome. However, this is a personal project
2 | and is casually maintained by Ben Brown. At this point, you should not
3 | expect a fully operational open source project.
4 |
5 | - I reserve the right to reject any contribution for any reason.
6 |
7 | - Our goal is to avoid using any large framework or dependency such as React.
8 | PRs that introduce new dependencies require special justification.
9 |
10 | - Before doing any major work, first open an issue describing the problem
11 | that needs solving. Have a discussion with @benbrown about it.
12 |
13 |
--------------------------------------------------------------------------------
/Caddyfile:
--------------------------------------------------------------------------------
1 | localhost {
2 | reverse_proxy :3000
3 | }
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-alpine
2 |
3 | # Create volume for persistent data
4 | VOLUME /app/.data
5 |
6 | # Create app directory
7 | WORKDIR /app
8 |
9 | # Install deps
10 | COPY package.json ./
11 | COPY package-lock.json ./
12 | RUN npm ci --omit=dev --ignore-scripts
13 |
14 | # Copy source
15 | COPY . .
16 |
17 | # Env Vars
18 | ENV PORT=3000
19 | ENV DOMAIN=""
20 | ENV USERNAME=""
21 | ENV PASS=""
22 |
23 | # Expose port
24 | EXPOSE $PORT
25 |
26 | # Start program
27 | CMD [ "npm", "start" ]
28 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright Ben Brown
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SHUTTLECRAFT by Ben Brown
2 |
3 | This is a project to create an "easy" way to participate in the ActivityPub "Fediverse" and other indie web protocols like RSS.
4 | This was created and is maintained by [Ben Brown](https://benbrown.com).
5 |
6 | Currently, this means:
7 |
8 | - a stand-alone NodeJS web application
9 | - with no external service dependencies
10 | - that is hostable on Glitch or commodity virtualhost
11 |
12 | Including features:
13 | - Follow people (on Mastodon, other instances)
14 | - Compose posts and deliver on the web, and also via ActivityPub, RSS
15 | - Fave, boost and reply to posts
16 | - View notifications
17 | - Send and receive DMs
18 | - Block people or instances
19 |
20 | Not yet supported:
21 | - Media uploads
22 |
23 | ## Warning: Experimental Software!
24 |
25 | This software should be considered an EXPERIMENTAL PROTOTYPE.
26 | Do not use it to store or exchange sensitive information.
27 |
28 | - This software creates publicly available web endpoints.
29 | - This software sends outbound web requests.
30 | - This software reads and writes to the filesystem!
31 | - This software has not been audited for potential security problems!!
32 |
33 | Because of the way the Mastodon works, once you start to engage with
34 | users on other instances, you will start to receive traffic from a
35 | wide array of other instances -- not all of which is necessary or
36 | relevant to you. As a result, operating this software on a small basis
37 | may result in unexpected amounts of incoming traffic.
38 |
39 | ## Warning: Known limitations!
40 |
41 | My goal with this app is to not use any major external services.
42 | As a result, all data is written as PLAIN TEXT FILES to the disk.
43 |
44 | Right now, the app builds an IN-MEMORY INDEX of EVERY SINGLE POST.
45 | This will work for several thousand posts, but ... maybe not for 10,000s of posts.
46 | I'm not sure how far it will go. I have ideas about being able to
47 | shard the index into multiple files and page through it, etc. But.
48 |
49 | ALSO, there is nothing fancy happening in terms of queuing or rate
50 | limiting outgoing posts. When you post, it will send out HTTP requests
51 | right away, all at once. This may cause issues.
52 |
53 | ## Acknowledgements
54 |
55 | This project owes a great debt to @dariusk's excellent [express-activitypub](https://github.com/dariusk/express-activitypub) repo.
56 | My work started from his reference implementation, and there are many lines of code cribbed from his work.
57 |
58 | ## Bug Reports & Contributions
59 |
60 | Please file bugs on Github:
61 | https://github.com/benbrown/shuttlecraft/issues
62 |
63 | Please read the [contributor's guide](CONTRIBUTING.md) before sending pull requests.
64 |
65 | ## Install
66 |
67 | Quick start: [Remix on Glitch](#easiest-glitch)
68 |
69 | Clone the repo:
70 | `git clone git@github.com:benbrown/shuttlecraft.git`
71 |
72 | Enter folder:
73 | `cd shuttlecraft`
74 |
75 | Install node dependencies:
76 | `npm install`
77 |
78 | You are ready to run! But first, set your configuration.
79 |
80 | When you are ready to start, run:
81 | `npm start`
82 |
83 | ## Config
84 |
85 | Initial configuration of your instance is done by editing the
86 | .env file to include your desired USERNAME, PASSWORD, and DOMAIN NAME.
87 | These values MUST BE SET before you launch the application, as
88 | they are used to generate your account details, including your
89 | Fediverse actor ID.
90 |
91 | In the .env file, put:
92 |
93 | ```
94 | USERNAME=yourusername
95 | PASS=yourpasswordforadmintools
96 | DOMAIN=yourdomainname
97 | PORT=3000
98 | ```
99 |
100 | USERNAME and PASS are required to login to the private dashboard tools.
101 |
102 | When you launch the app for the first time, these values will be used
103 | to create the `.data/account.json` file which is the source of your
104 | public account information, and will be used for many operations.
105 |
106 | There is currently no UI built to view or manage your account. If you
107 | need to make updates, edit the JSON directly.
108 |
109 | HOWEVER PLEASE NOTE that your ID is a real URL, and it must reflect
110 | the real URL served by this app. Also note that it is embedded in
111 | every post you write - so if you change values in the `account.json` file,
112 | your previous posts may break.
113 |
114 |
115 | ## Login
116 |
117 | To login, visit `https://yourdomain.com/private` and provide the username and password from your .env file
118 |
119 |
120 |
121 | ## Debugging
122 |
123 | If you want more logging or want to see what is happening in the background,
124 | enable debugging by adding DEBUG=ono:* to the .env file, or starting the app
125 | with:
126 |
127 | `DEBUG=ono:* npm start`
128 |
129 | ## Where is my data?
130 |
131 | All of the data is stored in the `.data` folder in JSON files.
132 |
133 | Incoming activities will be in `.data/activitystream`. Each incoming
134 | post is in a dated folder, for example `2022/12-01/GUID.json`
135 |
136 | Local posts are in `.data/posts`
137 |
138 | Cached user information is in `.data/users`
139 |
140 | Follower list, following list, like list, boost list, block list,
141 | and notifications can all be found in their own files at the root
142 | of the `.data` folder. This is your data! Back it up if you care
143 | about it.
144 |
145 |
146 | ## Host
147 |
148 | This is a node app that runs by default on port 3000, or the port
149 | specified in the .env file.
150 |
151 | In order to play nice with the fediverse, it must be hosted on an
152 | SSL-enabled endpoint.
153 |
154 | ### Easiest: Glitch
155 |
156 | Use Glitch to create a new project! Glitch will provide you with hosting for your instance of Shuttlecraft,
157 | and you can start for FREE!
158 |
159 | It all starts when you click this link -> [Remix this project on Glitch](https://glitch.com/edit/#!/import/github/benbrown/shuttlecraft) <--
160 |
161 | WHOA! What happened? Well, a copy of the Shuttlecraft code was sent to a new, unique, owned-by-you web server and it started getting set up. You just need to make it yours by following these steps:
162 |
163 | 1. First, make sure the URL of your Glitch project is the one you like. You can change it in the "Settings" menu.
164 | 2. Then, configure the options [as described above](#config) using the .env editor.
165 | 3. Finally, login to the dashboard at `https://yourdomain.glitch.me/private`.
166 | 4. Done!
167 |
168 | ### Basic: Reverse proxy
169 |
170 | 1. Clone the repo to your own server.
171 | 2. Configure it and set it up to run on a port of your choosing.
172 | 3. Configure Caddy or Nginx with a Certbot SSL certificate.
173 | 4. Configure your domain to proxy requests to the localhost port.
174 |
175 | A sample `Caddyfile` is included in the repo. [Install Caddy](https://caddyserver.com/download) and run:
176 | ```
177 | caddy run --config Caddyfile
178 | ```
179 |
180 | ### Advanced: Docker
181 |
182 | 1. Clone the repo.
183 | 2. Build the image:
184 | ```
185 | docker build . --tag "${yourRegistryUsername}/shuttlecraft:latest"
186 | ```
187 | 3. Test locally:
188 | ```
189 | docker run -e PORT=3000 -e DOMAIN="your-domain.com" -e USERNAME="yourUsername" -e PASS="yourPassword" -p "3000:3000" "${yourRegistryUsername}/shuttlecraft"
190 | ```
191 | 4. Push the image to your registry:
192 | ```
193 | docker push "${yourRegistryUsername}/shuttlecraft:latest"
194 | ```
195 | 5. Deploy the image to your container platform with the required environment variables (`DOMAIN`, `USERNAME`, `PASS`).
196 | 6. Configure a web service to proxy requests to the container port and provide HTTPS (see "Reverse proxy" above).
197 |
198 | ## Customize
199 |
200 | This app uses HandlebarsJS for templating.
201 |
202 | Customize the public pages:
203 | - Templates are in `design/public/home.handlebars` and `design/public/note.handlebars` and `design/layouts/public.handlebars`
204 | - CSS is in `public/css/main.css`
205 |
206 | Customize your avatar:
207 | - Replace `public/images/avatar.png`
208 | - As necessary, update the url in `.data/account.json` inside the actor.icon.url field
209 |
210 | Customize the backend:
211 | - Templates are in `design/dashboard.handlebars` and `design/notifications.handlebars` and `design/layouts/private.handlebars`
212 | - Some common components in `design/partials`
213 | - CSS in `public/css/secret.css`
214 |
215 | To block users or instances:
216 | - Add an entry to the file at `.data/blocks`
217 | - You can block a user using their actor ID (something like https://foo.bar/@jerk) or their entire domain (https://foo.bar/)
218 | - Restart the app
219 |
--------------------------------------------------------------------------------
/design/dashboard.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{prefs.icons.latest}} Latest
4 |
5 |
6 |
7 | {{#if activitystream}}
8 | {{#each activitystream}}
9 | {{#with this}}
10 |
11 | {{#if boost}}
12 |
15 | {{/if}}
16 | {{> note note=note me=../me}}
17 |
18 | {{/with}}
19 | {{/each}}
20 | {{else}}
21 |
22 |
Follow some people to fill your feed with posts.
23 |
I suggest following me!
24 | I'm benbrown@hackers.town
25 |
26 |
27 | {{/if}}
28 |
29 |
30 |
31 | {{#if next}}
32 |
More
33 | {{/if}}
34 |
35 |
44 |
--------------------------------------------------------------------------------
/design/dms.handlebars:
--------------------------------------------------------------------------------
1 | {{#if error}}
2 |
3 | {{error.message}}
4 |
5 | {{else}}
6 |
7 | {{#if feed}}
8 |
9 |
10 |
11 | {{#each inbox}}
12 | {{> dm message=this me=../me}}
13 | {{/each}}
14 |
15 | {{> minicomposer inReplyTo=lastIncoming to=feed.id}}
16 |
17 | {{else}}
18 |
19 |
20 | Select a conversation. To create a new one, navigate to a profile.
21 |
22 | {{/if}}
23 |
24 | {{/if}}
25 |
--------------------------------------------------------------------------------
/design/feeds.handlebars:
--------------------------------------------------------------------------------
1 | {{#if error}}
2 |
3 | {{error.message}}
4 |
5 | {{else}}
6 |
7 | {{#if feed}}
8 | {{> profileHeader actor=feed nobio=true}}
9 | {{/if}}
10 | {{#if activitystream}}
11 | {{#each activitystream}}
12 | {{#with this}}
13 |
14 | {{#if boost}}
15 |
18 | {{> note note=note}}
19 | {{else}}
20 | {{> note note=note hidebyline=true}}
21 | {{/if}}
22 |
23 | {{/with}}
24 | {{/each}}
25 | {{else}}
26 |
27 |
30 | {{/if}}
31 |
32 | {{#if next}}
33 |
More
34 | {{/if}}
35 |
36 |
37 | {{/if}}
38 |
--------------------------------------------------------------------------------
/design/findresults.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
9 | {{#if results}}
10 | {{#each results}}
11 |
12 | {{> personCard actor=this nobio=true}}
13 |
14 | {{/each}}
15 | {{else}}
16 |
17 | Nobody results
18 |
19 | {{/if}}
20 |
21 |
--------------------------------------------------------------------------------
/design/followers.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 | {{#each followers}}
7 |
8 | {{> personCard actor=this}}
9 |
10 | {{/each}}
11 |
12 | {{> peopleTools}}
13 |
--------------------------------------------------------------------------------
/design/following.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 | {{#each following}}
7 |
8 | {{> personCard actor=this}}
9 |
10 | {{/each}}
11 |
12 | {{> peopleTools}}
13 |
--------------------------------------------------------------------------------
/design/layouts/activity.handlebars:
--------------------------------------------------------------------------------
1 |
2 | {{{body}}}
3 |
--------------------------------------------------------------------------------
/design/layouts/private.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{me.preferredUsername}}'s shuttlecraft
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
24 |
67 |
68 | {{{body}}}
69 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/design/layouts/public.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{me.preferredUsername}}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{{body}}}
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/design/notifications.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{prefs.icons.notifications}} Notifications
4 |
5 |
6 | {{#each notifications}}
7 |
8 | {{#with this}}
9 | {{#isEq notification.type "Announce"}}
10 |
11 |
12 | {{{...note.content}}}
13 |
14 | {{/isEq}}
15 | {{#isEq notification.type "Reply"}}
16 | {{> note actor=../actor note=../note}}
17 | {{/isEq}}
18 | {{#isEq notification.type "Mention"}}
19 |
20 | {{> note actor=../actor note=../note}}
21 | {{/isEq}}
22 | {{#isEq notification.type "Like"}}
23 |
24 |
25 | {{{...note.content}}}
26 |
27 | {{/isEq}}
28 | {{#isEq notification.type "Follow"}}
29 |
30 | {{> byline actor=../actor}}
31 | {{/isEq}}
32 | {{/with}}
33 |
34 | {{/each}}
35 |
36 | {{#if next}}
37 |
More
38 | {{/if}}
39 |
40 |
41 |
--------------------------------------------------------------------------------
/design/partials/avatar.handlebars:
--------------------------------------------------------------------------------
1 | {{#unless nolink}}{{/unless}}
2 | {{#if actor.icon.url}}
3 |
4 | {{else}}
5 |
6 | {{/if}}
7 | {{#unless nolink}} {{/unless}}
--------------------------------------------------------------------------------
/design/partials/byline.handlebars:
--------------------------------------------------------------------------------
1 |
2 | {{> avatar actor=actor}}
3 |
7 |
8 |
--------------------------------------------------------------------------------
/design/partials/composer.handlebars:
--------------------------------------------------------------------------------
1 |
2 | {{#if originalPost}}
3 |
4 |
5 | {{> byline actor=actor}}
6 | {{{originalPost.content}}}
7 |
8 | {{else}}
9 | {{#if prev}}
10 |
11 | {{else}}
12 | {{/if}}
13 | {{/if}}
14 |
15 |
41 |
42 |
54 |
--------------------------------------------------------------------------------
/design/partials/dm.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{{message.content}}}
4 |
5 |
6 | {{timesince message.published}}
7 |
8 |
9 |
--------------------------------------------------------------------------------
/design/partials/feeds.handlebars:
--------------------------------------------------------------------------------
1 | {{#if feeds}}
2 | {{#each feeds}}
3 |
4 |
5 | {{> avatar actor=actor nolink=true}}
6 | {{or actor.preferredName actor.name}}
7 |
8 |
9 | {{/each}}
10 | {{/if}}
11 |
--------------------------------------------------------------------------------
/design/partials/minicomposer.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Send
9 |
10 |
11 |
--------------------------------------------------------------------------------
/design/partials/note.handlebars:
--------------------------------------------------------------------------------
1 | {{#unless hidebyline}}
2 | {{> byline actor=actor}}
3 | {{/unless}}
4 | {{#if note.summary}}
5 |
6 |
⚠️ {{note.summary}}
7 |
10 |
11 | {{/if}}
12 |
13 | {{{note.content}}}
14 |
15 | {{#if note.inReplyTo}}
16 |
Show Thread
17 | {{/if}}
18 |
19 | {{#each note.attachment}}
20 |
21 | {{#isImage mediaType}}
22 |
23 | {{/isImage}}
24 | {{#isVideo mediaType}}
25 |
26 | {{/isVideo}}
27 |
28 | {{/each}}
29 |
48 |
49 |
--------------------------------------------------------------------------------
/design/partials/peopleTools.handlebars:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/design/partials/personCard.handlebars:
--------------------------------------------------------------------------------
1 |
2 | {{> avatar actor=actor}}
3 |
4 |
{{actor.name}}
5 |
{{getUsername actor.id}}
6 | {{#unless nobio}}
7 | {{{summary}}}
8 | {{/unless}}
9 |
10 |
--------------------------------------------------------------------------------
/design/partials/profileHeader.handlebars:
--------------------------------------------------------------------------------
1 |
6 |
18 |
19 |
20 |
{{actor.name}}
21 |
{{getUsername actor.id}}
22 | {{#if actor.isFollower}}
23 |
Follows you
24 | {{/if}}
25 |
26 |
27 | Details
28 | {{{actor.summary}}}
29 |
30 | {{#if actor.attachment}}
31 |
32 | {{#each actor.attachment}}
33 |
34 | {{this.name}}
35 |
36 |
37 | {{{this.value}}}
38 |
39 | {{/each}}
40 |
41 | {{/if}}
42 |
43 |
44 |
--------------------------------------------------------------------------------
/design/prefs.handlebars:
--------------------------------------------------------------------------------
1 | {{> peopleTools}}
2 |
3 |
4 |
5 | {{#isEq queue.state 0}}✅{{/isEq}}
6 | {{#isEq queue.state 1}}🟢{{/isEq}}
7 | {{#isEq queue.state 3}}🔴{{/isEq}}
8 | {{#if queue.size}}
9 | {{#if queue.shouldRun}}Running.{{/if}}
10 | {{queue.size}} items remain.
11 | {{else}}
12 | Queue empty
13 | {{/if}}
14 |
15 |
16 |
--------------------------------------------------------------------------------
/design/public/home.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Follow this site on Mastodon or other fediverse clients:
5 |
6 |
7 |
8 |
13 |
20 |
21 |
22 |
{{actor.name}}
23 |
{{getUsername actor.id}}
24 |
25 | {{{actor.summary}}}
26 |
27 | {{#if actor.attachment}}
28 |
29 | {{#each actor.attachment}}
30 |
31 | {{this.name}}
32 |
33 |
34 | {{{this.value}}}
35 |
36 | {{/each}}
37 |
38 | {{/if}}
39 |
40 |
41 |
45 | {{#each activitystream}}
46 |
47 | {{#with this}}
48 |
49 | {{#if summary}}
50 | content warning: {{summary}}
51 | {{/if}}
52 | {{{content}}}
53 |
54 | {{#if this.inReplyTo}}
55 |
Show Thread
56 | {{/if}}
57 |
58 | {{#each note.attachment}}
59 |
60 | {{#isImage mediaType}}
61 |
62 | {{/isImage}}
63 | {{#isVideo mediaType}}
64 |
65 | {{/isVideo}}
66 |
67 | {{/each}}
68 |
69 |
85 |
86 | {{/with}}
87 |
88 | {{/each}}
89 |
More
90 |
91 |
--------------------------------------------------------------------------------
/design/public/note.handlebars:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Follow this site on Mastodon or other fediverse clients:
5 |
6 |
7 |
8 |
12 | {{#each activitystream}}
13 |
14 | {{#with this}}
15 |
22 | {{#if note.summary}}
23 |
24 | content warning: {{note.summary}}
25 |
26 | {{/if}}
27 |
28 | {{{note.content}}}
29 |
30 | {{#each note.attachment}}
31 |
32 | {{#isImage mediaType}}
33 |
34 | {{/isImage}}
35 | {{#isVideo mediaType}}
36 |
37 | {{/isVideo}}
38 |
39 | {{/each}}
40 |
56 |
57 | {{/with}}
58 |
59 | {{/each}}
60 |
61 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { create } from 'express-handlebars';
3 | import cookieParser from 'cookie-parser';
4 |
5 | import dotenv from 'dotenv';
6 |
7 | import bodyParser from 'body-parser';
8 | import cors from 'cors';
9 | import http from 'http';
10 | import basicAuth from 'express-basic-auth';
11 | import moment from 'moment';
12 | import { ActivityPub } from './lib/ActivityPub.js';
13 | import { ensureAccount } from './lib/account.js';
14 | import { account, webfinger, inbox, outbox, admin, notes, publicFacing } from './routes/index.js';
15 |
16 | // load process.env from .env file
17 | dotenv.config();
18 | const { USERNAME, PASS, DOMAIN, PORT } = process.env;
19 | ['USERNAME', 'PASS', 'DOMAIN'].forEach(required => {
20 | if (!process.env[required]) {
21 | console.error(`Missing required environment variable: \`${required}\`. Exiting.`);
22 | process.exit(1);
23 | }
24 | });
25 |
26 | const PATH_TO_TEMPLATES = './design';
27 | const app = express();
28 | const hbs = create({
29 | helpers: {
30 | isVideo: (str, options) => {
31 | if (str && str.includes('video')) return options.fn(this);
32 | },
33 | isImage: (str, options) => {
34 | if (str && str.includes('image')) return options.fn(this);
35 | },
36 | isEq: (a, b, options) => {
37 | // eslint-disable-next-line
38 | if (a == b) return options.fn(this);
39 | },
40 | or: (a, b, options) => {
41 | return a || b;
42 | },
43 | timesince: date => {
44 | return moment(date).fromNow();
45 | },
46 | getUsername: user => {
47 | return ActivityPub.getUsername(user);
48 | },
49 | stripProtocol: str => str.replace(/^https:\/\//, ''),
50 | stripHTML: str =>
51 | str
52 | .replace(/<\/p>/, '\n')
53 | .replace(/(<([^>]+)>)/gi, '')
54 | .trim()
55 | }
56 | });
57 |
58 | app.set('domain', DOMAIN);
59 | app.set('port', process.env.PORT || PORT || 3000);
60 | app.set('port-https', process.env.PORT_HTTPS || 8443);
61 | app.engine('handlebars', hbs.engine);
62 | app.set('views', PATH_TO_TEMPLATES);
63 | app.set('view engine', 'handlebars');
64 | app.use(
65 | bodyParser.json({
66 | type: 'application/activity+json'
67 | })
68 | ); // support json encoded bodies
69 | app.use(
70 | bodyParser.json({
71 | type: 'application/json'
72 | })
73 | ); // support json encoded bodies
74 | app.use(
75 | bodyParser.json({
76 | type: 'application/ld+json'
77 | })
78 | ); // support json encoded bodies
79 |
80 | app.use(cookieParser());
81 |
82 | app.use(
83 | bodyParser.urlencoded({
84 | extended: true
85 | })
86 | ); // support encoded bodies
87 |
88 | // basic http authorizer
89 | const basicUserAuth = basicAuth({
90 | authorizer: asyncAuthorizer,
91 | authorizeAsync: true,
92 | challenge: true
93 | });
94 |
95 | function asyncAuthorizer(username, password, cb) {
96 | let isAuthorized = false;
97 | const isPasswordAuthorized = username === USERNAME;
98 | const isUsernameAuthorized = password === PASS;
99 | isAuthorized = isPasswordAuthorized && isUsernameAuthorized;
100 | if (isAuthorized) {
101 | return cb(null, true);
102 | } else {
103 | return cb(null, false);
104 | }
105 | }
106 |
107 | // Load/create account file
108 | ensureAccount(USERNAME, DOMAIN).then(myaccount => {
109 | const authWrapper = (req, res, next) => {
110 | if (req.cookies.token) {
111 | if (req.cookies.token === myaccount.apikey) {
112 | return next();
113 | }
114 | }
115 | return basicUserAuth(req, res, next);
116 | };
117 |
118 | // set the server to use the main account as its primary actor
119 | ActivityPub.account = myaccount;
120 | console.log(`BOOTING SERVER FOR ACCOUNT: ${myaccount.actor.preferredUsername}`);
121 | console.log(`ACCESS DASHBOARD: https://${DOMAIN}/private`);
122 |
123 | // set up globals
124 | app.set('domain', DOMAIN);
125 | app.set('account', myaccount);
126 |
127 | // serve webfinger response
128 | app.use('/.well-known/webfinger', cors(), webfinger);
129 | // server user profile and follower list
130 | app.use('/u', cors(), account);
131 |
132 | // serve individual posts
133 | app.use('/m', cors(), notes);
134 |
135 | // handle incoming requests
136 | app.use('/api/inbox', cors(), inbox);
137 | app.use('/api/outbox', cors(), outbox);
138 |
139 | app.use(
140 | '/private',
141 | cors({
142 | credentials: true,
143 | origin: true
144 | }),
145 | authWrapper,
146 | admin
147 | );
148 | app.use('/', cors(), publicFacing);
149 | app.use('/', express.static('public/'));
150 |
151 | http.createServer(app).listen(app.get('port'), function () {
152 | console.log('Express server listening on port ' + app.get('port'));
153 | });
154 | });
155 |
--------------------------------------------------------------------------------
/lib/ActivityPub.js:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch';
2 | import crypto from 'crypto';
3 | import debug from 'debug';
4 | import { queue } from './queue.js';
5 | const logger = debug('ActivityPub');
6 |
7 | /**
8 | * ActivityPubClient - a class for sending and fetching ActivityPub content
9 | */
10 | export class ActivityPubClient {
11 | constructor(account) {
12 | logger('Initializing ActivityPub client for user:', account);
13 | if (account) {
14 | this.account = account;
15 | }
16 | }
17 |
18 | set actor(actor) {
19 | this._actor = actor;
20 | }
21 |
22 | get actor() {
23 | return this._actor;
24 | }
25 |
26 | set account(account) {
27 | logger('Setting account:', account);
28 | this._account = account;
29 | this._actor = account?.actor;
30 | }
31 |
32 | get account() {
33 | return this._account;
34 | }
35 |
36 | async webfinger(username) {
37 | const { targetDomain } = this.getUsernameDomain(username);
38 |
39 | const webfingerUrl = `https://${targetDomain}/.well-known/webfinger?resource=acct:${username}`;
40 |
41 | logger(`fetch webfinger ${webfingerUrl}`);
42 | const finger = await fetch(webfingerUrl, {
43 | headers: {
44 | Accept: 'application/jrd+json, application/json, application/ld+json'
45 | }
46 | });
47 | if (finger.ok) {
48 | const webfinger = await finger.json();
49 | return webfinger;
50 | } else {
51 | throw new Error(`could not get webfinger ${webfingerUrl}: ${finger.status}`);
52 | }
53 | }
54 |
55 | async fetchActor(userId) {
56 | const actorQuery = await ActivityPub.fetch(userId, {});
57 | if (actorQuery.ok) {
58 | const actor = await actorQuery.json();
59 | return actor;
60 | } else {
61 | // logger('failed to load actor', actorQuery.status, actorQuery.statusText, await actorQuery.text());
62 | throw new Error('failed to load actor');
63 | }
64 | }
65 |
66 | /**
67 | * Fetch an ActivityPub URL using the current actor to sign the request
68 | * @param {*} targetUrl url of activitypub resource
69 | * @param {*} options options for the fetch, excluding header
70 | * @returns a fetch promise
71 | */
72 | async fetch(targetUrl, options) {
73 | logger('Fetch:', targetUrl);
74 |
75 | const url = new URL(targetUrl);
76 | const urlFragment = url.pathname + (url.searchParams.toString() ? `?${url.searchParams.toString()}` : '');
77 |
78 | const signer = crypto.createSign('sha256');
79 | const d = new Date();
80 | const stringToSign = `(request-target): get ${urlFragment}\nhost: ${url.hostname}\ndate: ${d.toUTCString()}`;
81 | signer.update(stringToSign);
82 | signer.end();
83 | const signature = signer.sign(this.account.privateKey);
84 | const signatureB64 = signature.toString('base64');
85 | const header = `keyId="${this.actor.publicKey.id}",headers="(request-target) host date",signature="${signatureB64}"`;
86 | options.headers = {
87 | Date: d.toUTCString(),
88 | Host: url.hostname,
89 | Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
90 | Signature: header
91 | };
92 |
93 | const controller = new AbortController();
94 | setTimeout(() => controller.abort(), 5000);
95 | options.signal = controller.signal;
96 |
97 | const query = fetch(targetUrl, options);
98 |
99 | return query;
100 | }
101 |
102 | /**
103 | * Send an ActivityPub activity to a user
104 | * @param {*} recipient
105 | * @param {*} message
106 | * @returns a fetch result
107 | */
108 | async send(recipient, message) {
109 | queue.enqueue(() => {
110 | let url;
111 | try {
112 | url = new URL(recipient.inbox);
113 | } catch (err) {
114 | console.error('INVALID INBOX URL', recipient);
115 | throw err;
116 | }
117 | const inboxFragment = url.pathname;
118 |
119 | const digestHash = crypto.createHash('sha256').update(JSON.stringify(message)).digest('base64');
120 | const signer = crypto.createSign('sha256');
121 | const d = new Date();
122 | const stringToSign = `(request-target): post ${inboxFragment}\nhost: ${
123 | url.hostname
124 | }\ndate: ${d.toUTCString()}\ndigest: SHA-256=${digestHash}`;
125 | signer.update(stringToSign);
126 | signer.end();
127 | const signature = signer.sign(this.account.privateKey);
128 | const signatureB64 = signature.toString('base64');
129 | const header = `keyId="${this.actor.publicKey.id}",headers="(request-target) host date digest",signature="${signatureB64}"`;
130 |
131 | logger('OUTBOUND TO ', recipient.inbox);
132 | logger('MESSAGE', message);
133 |
134 | const controller = new AbortController();
135 | setTimeout(() => controller.abort(), 10000);
136 | return fetch(
137 | recipient.inbox,
138 | {
139 | headers: {
140 | Host: url.hostname,
141 | 'Content-type': 'application/activity+json',
142 | Date: d.toUTCString(),
143 | Digest: `SHA-256=${digestHash}`,
144 | Signature: header
145 | },
146 | method: 'POST',
147 | json: true,
148 | body: JSON.stringify(message),
149 | signal: controller.signal
150 | },
151 | function (error, response) {
152 | if (error) {
153 | console.error('Error sending outbound message:', error, response);
154 | } else {
155 | logger('Response', response.status);
156 | }
157 | }
158 | );
159 | });
160 | }
161 |
162 | /**
163 | * Send a like message to the author of a post
164 | * @param {*} post activity being liked
165 | * @param {*} recipient actor record for author of post
166 | * @returns
167 | */
168 | async sendLike(post, recipient) {
169 | const guid = crypto.randomBytes(16).toString('hex');
170 | const message = {
171 | '@context': 'https://www.w3.org/ns/activitystreams',
172 | id: `${this.actor.id}/likes/${guid}`,
173 | type: 'Like',
174 | actor: this.actor.id,
175 | object: post.id
176 | };
177 |
178 | ActivityPub.send(recipient, message);
179 |
180 | // return the guid to make this undoable.
181 | return message;
182 | }
183 |
184 | /**
185 | * Send an undo message about a like that was sent previously.
186 | * @param {*} post post that is being unliked
187 | * @param {*} recipient actor record for author of post
188 | * @param {*} originalActivityId id of original outbound like activity that is being undone
189 | * @returns
190 | */
191 | async sendUndoLike(post, recipient, originalActivityId) {
192 | const message = {
193 | '@context': 'https://www.w3.org/ns/activitystreams',
194 | id: `${originalActivityId}/undo`,
195 | type: 'Undo',
196 | actor: this.actor.id,
197 | object: {
198 | id: `${originalActivityId}`,
199 | type: 'Like',
200 | actor: this.actor.id,
201 | object: post.id
202 | }
203 | };
204 | ActivityPub.send(recipient, message);
205 | return message;
206 | }
207 |
208 | /**
209 | * Send a follow request
210 | * @param {*} recipient
211 | * @returns
212 | */
213 | async sendFollow(recipient) {
214 | const guid = crypto.randomBytes(16).toString('hex');
215 | const message = {
216 | '@context': 'https://www.w3.org/ns/activitystreams',
217 | id: `${this.actor.id}/follows/${guid}`,
218 | type: 'Follow',
219 | actor: this.actor.id,
220 | object: recipient.id
221 | };
222 | ActivityPub.send(recipient, message);
223 |
224 | // return the guid to make this undoable.
225 | return message;
226 | }
227 |
228 | /**
229 | * Send an undo about a previously sent follow
230 | * @param {*} recipient
231 | * @param {*} originalActivityId
232 | * @returns
233 | */
234 | async sendUndoFollow(recipient, originalActivityId) {
235 | const message = {
236 | '@context': 'https://www.w3.org/ns/activitystreams',
237 | id: `${originalActivityId}/undo`,
238 | type: 'Undo',
239 | actor: this.actor.id,
240 | object: {
241 | id: originalActivityId,
242 | type: 'Follow',
243 | actor: this.actor.id,
244 | object: recipient.id
245 | }
246 | };
247 | ActivityPub.send(recipient, message);
248 |
249 | // return the guid to make this undoable.
250 | return message;
251 | }
252 |
253 | /**
254 | * Send an Accept for an incoming follow request
255 | * @param {*} followRequest
256 | */
257 | async sendAccept(recipient, followRequest) {
258 | const guid = crypto.randomBytes(16).toString('hex');
259 | const message = {
260 | '@context': 'https://www.w3.org/ns/activitystreams',
261 | id: `${this.actor.id}/accept/${guid}`,
262 | type: 'Accept',
263 | actor: this.actor.id,
264 | object: followRequest
265 | };
266 | ActivityPub.send(recipient, message);
267 |
268 | return message;
269 | }
270 |
271 | /**
272 | * Send an outbound update activity to a follower or recipient of a message
273 | * @param {*} recipient
274 | * @param {*} object
275 | * @returns
276 | */
277 | async sendUpdate(recipient, object) {
278 | const message = {
279 | '@context': 'https://www.w3.org/ns/activitystreams',
280 | id: `${object.id}/activity`,
281 | published: object.published,
282 | type: 'Update',
283 | actor: this.actor.id,
284 | object,
285 | to: object.to,
286 | cc: object.cc
287 | };
288 | ActivityPub.send(recipient, message);
289 | return message;
290 | }
291 |
292 | /**
293 | * Send an outbound create activity to a follower or recipient of a message
294 | * @param {*} recipient
295 | * @param {*} object
296 | * @returns
297 | */
298 | async sendCreate(recipient, object) {
299 | const message = {
300 | '@context': 'https://www.w3.org/ns/activitystreams',
301 | id: `${object.id}/activity`,
302 | published: object.published,
303 | type: 'Create',
304 | actor: this.actor.id,
305 | object,
306 | to: object.to,
307 | cc: object.cc
308 | };
309 | ActivityPub.send(recipient, message);
310 | return message;
311 | }
312 |
313 | /**
314 | * Send a boost for a specific post to the posts author and our followers
315 | * @param {*} primaryRecipient
316 | * @param {*} post
317 | * @param {*} followers
318 | * @returns
319 | */
320 | async sendBoost(primaryRecipient, post, followers) {
321 | const guid = crypto.randomBytes(16).toString('hex');
322 |
323 | // send to followers and original poster
324 | const recipients = [
325 | this.actor.followers, // this is a reference to the follower list that we will dereference later
326 | primaryRecipient.id // this is a reference to the recipient
327 | ];
328 |
329 | const message = {
330 | '@context': 'https://www.w3.org/ns/activitystreams',
331 | id: `${this.actor.id}/boosts/${guid}`,
332 | type: 'Announce',
333 | actor: this.actor.id,
334 | published: new Date().toISOString(),
335 | object: post.id,
336 | to: ['https://www.w3.org/ns/activitystreams#Public'],
337 | cc: recipients
338 | };
339 |
340 | // deliver outbound messages to all recipients
341 | recipients.forEach(recipient => {
342 | // if the recipient is "my followers", send it to them
343 | if (recipient === this.actor.followers) {
344 | followers.forEach(follower => {
345 | ActivityPub.send(follower, message);
346 | });
347 | } else {
348 | // otherwise, send it directly to the person
349 | ActivityPub.send(primaryRecipient, message);
350 | }
351 | });
352 |
353 | // return the guid to make this undoable.
354 | return message;
355 | }
356 |
357 | /**
358 | * Send an undo of a previously sent boost
359 | * @param {*} primaryRecipient
360 | * @param {*} post
361 | * @param {*} followers
362 | * @param {*} originalActivityId
363 | * @returns
364 | */
365 | async sendUndoBoost(primaryRecipient, post, followers, originalActivityId) {
366 | // send to followers and original poster
367 | const recipients = [
368 | this.actor.followers, // this is a reference to the follower list that we will dereference later
369 | post.attributedTo // this is a reference to the recipient
370 | ];
371 |
372 | const message = {
373 | '@context': 'https://www.w3.org/ns/activitystreams',
374 | id: `${originalActivityId}/undo`,
375 | type: 'Undo',
376 | actor: this.actor.id,
377 | object: {
378 | id: originalActivityId,
379 | type: 'Announce',
380 | actor: this.actor.id,
381 | object: post.id
382 | },
383 | to: ['https://www.w3.org/ns/activitystreams#Public'],
384 | cc: recipients
385 | };
386 |
387 | // deliver outbound messages to all recipients
388 | recipients.forEach(recipient => {
389 | // if the recipient is "my followers", send it to them
390 | if (recipient === this.actor.followers) {
391 | followers.forEach(follower => {
392 | ActivityPub.send(follower, message);
393 | });
394 | } else {
395 | // otherwise, send it directly to the person
396 | ActivityPub.send(primaryRecipient, message);
397 | }
398 | });
399 |
400 | // return the guid to make this undoable.
401 | return message;
402 | }
403 |
404 | getUsernameDomain(userIdorName) {
405 | let targetDomain, username;
406 | if (userIdorName.startsWith('https://')) {
407 | const actor = new URL(userIdorName);
408 | targetDomain = actor.hostname;
409 | username = actor.pathname.split(/\//);
410 | username = username[username.length - 1];
411 | } else {
412 | // handle leading @
413 | [username, targetDomain] = userIdorName.replace(/^@/, '').split('@');
414 | }
415 |
416 | return {
417 | username,
418 | targetDomain
419 | };
420 | }
421 |
422 | getUsername(userIdorName) {
423 | const { username, targetDomain } = this.getUsernameDomain(userIdorName);
424 | return `${username}@${targetDomain}`;
425 | }
426 |
427 | async fetchOutbox(actor) {
428 | if (actor.outbox) {
429 | try {
430 | const actorQuery = await ActivityPub.fetch(actor.outbox, {});
431 | if (actorQuery.ok) {
432 | const rootOutbox = await actorQuery.json();
433 | let items = [];
434 | let outboxPage;
435 | // find the first element.
436 | if (rootOutbox.first) {
437 | if (typeof rootOutbox.first === 'string') {
438 | const pageQuery = await ActivityPub.fetch(rootOutbox.first, {});
439 | if (pageQuery.ok) {
440 | outboxPage = await pageQuery.json();
441 | items = outboxPage.orderedItems || [];
442 | } else {
443 | logger(
444 | 'failed to load outbox first page',
445 | rootOutbox.first,
446 | pageQuery.status,
447 | pageQuery.statusText,
448 | await pageQuery.text()
449 | );
450 | }
451 | } else {
452 | items = rootOutbox.first.orderedItems || [];
453 | outboxPage = rootOutbox.first;
454 | }
455 | }
456 |
457 | return {
458 | outbox: rootOutbox,
459 | page: outboxPage,
460 | items
461 | };
462 | } else {
463 | logger(
464 | 'failed to load outbox index',
465 | actor.outbox,
466 | actorQuery.status,
467 | actorQuery.statusText,
468 | await actorQuery.text()
469 | );
470 | }
471 | } catch (err) {
472 | console.error(err);
473 | }
474 | }
475 | return [];
476 | }
477 |
478 | /**
479 | * Validate the signature on an incoming request to the inbox
480 | * @param {*} actor
481 | * @param {*} req
482 | * @returns true if signature is valid
483 | */
484 | validateSignature(actor, req) {
485 | const signature = {};
486 | req.headers.signature
487 | .split(/,/)
488 | .map(c => c.split(/=/))
489 | .forEach(([key, val]) => {
490 | signature[key] = val.replace(/^"/, '').replace(/"$/, '');
491 | return signature[key];
492 | });
493 | // construct string from headers
494 | const fields = signature.headers.split(/\s/);
495 | const str = fields
496 | .map(f => (f === '(request-target)' ? '(request-target): post /api/inbox' : `${f}: ${req.header(f)}`))
497 | .join('\n');
498 | try {
499 | if (actor) {
500 | const verifier = crypto.createVerify('RSA-SHA256');
501 | verifier.update(str);
502 | const res = verifier.verify(actor.publicKey.publicKeyPem, signature.signature, 'base64');
503 | return res;
504 | } else {
505 | return false;
506 | }
507 | } catch (err) {
508 | // console.error(err); // any server will get a lot of junk Deletes
509 | return false;
510 | }
511 | }
512 | }
513 |
514 | export const ActivityPub = new ActivityPubClient();
515 |
--------------------------------------------------------------------------------
/lib/Markdown.js:
--------------------------------------------------------------------------------
1 | /* This module contains the markdown renderer used to format posts
2 |
3 | By default, urls will be linkified with nofollow noopener and noreferrer attributes
4 | Override those attributes by setting LINK_ATTRIBUTES in the .env file
5 |
6 | Usage:
7 | const html = md.render(markdown);
8 |
9 | */
10 |
11 | import dotenv from 'dotenv';
12 | import MarkdownIt from 'markdown-it';
13 | dotenv.config();
14 |
15 | const md = new MarkdownIt({
16 | html: true,
17 | linkify: true
18 | });
19 |
20 | const LINK_ATTRIBUTES = process.env.LINK_ATTRIBUTES || 'nofollow noopener noreferrer';
21 |
22 | // customize the link formatter to include noopener noreferrer links
23 | // this prevents browsers from telling downstream pages about where the links came from
24 | // and protects the privacy of our users.
25 | // code from: https://publishing-project.rivendellweb.net/customizing-markdown-it/
26 | const proxy = (tokens, idx, options, env, self) => self.renderToken(tokens, idx, options);
27 | const defaultLinkOpenRenderer = md.renderer.rules.link_open || proxy;
28 | md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
29 | tokens[idx].attrJoin('rel', LINK_ATTRIBUTES);
30 | return defaultLinkOpenRenderer(tokens, idx, options, env, self);
31 | };
32 |
33 | export { md, LINK_ATTRIBUTES };
34 |
--------------------------------------------------------------------------------
/lib/account.js:
--------------------------------------------------------------------------------
1 | import fs, { existsSync } from 'fs';
2 | import path from 'path';
3 | import crypto from 'crypto';
4 | import dotenv from 'dotenv';
5 |
6 | import { sortByDate } from './theAlgorithm.js';
7 | import { ActivityPub } from './ActivityPub.js';
8 | import { fetchUser } from './users.js';
9 | import {
10 | INDEX,
11 | readJSONDictionary,
12 | writeJSONDictionary,
13 | followersFile,
14 | followingFile,
15 | blocksFile,
16 | notificationsFile,
17 | likesFile,
18 | accountFile,
19 | createFileName,
20 | getFileName,
21 | boostsFile,
22 | pathToDMs
23 | } from './storage.js';
24 | import { getActivity, createActivity, deleteActivity } from './notes.js';
25 |
26 | import debug from 'debug';
27 |
28 | import { md } from './Markdown.js';
29 | dotenv.config();
30 | const logger = debug('ono:account');
31 |
32 | const { DOMAIN } = process.env;
33 |
34 | export const getInboxIndex = () => {
35 | const inboxIndexPath = path.resolve(pathToDMs, `inboxes.json`);
36 | const inboxIndex = readJSONDictionary(inboxIndexPath, {});
37 | return inboxIndex;
38 | };
39 |
40 | export const writeInboxIndex = data => {
41 | const inboxIndexPath = path.resolve(pathToDMs, `inboxes.json`);
42 | writeJSONDictionary(inboxIndexPath, data);
43 | };
44 |
45 | export const getInbox = actorId => {
46 | const username = ActivityPub.getUsername(actorId);
47 |
48 | const inboxPath = path.resolve(pathToDMs, `${username}.json`);
49 |
50 | const inbox = readJSONDictionary(inboxPath, []);
51 | return inbox;
52 | };
53 |
54 | /**
55 | * given an activity, return true if the only addressee is this server's owner
56 | * @param {*} activity
57 | * @returns
58 | */
59 | export const addressedOnlyToMe = activity => {
60 | // load my info
61 | const actor = ActivityPub.actor;
62 |
63 | // get all addresses
64 | let addresses = activity.to;
65 | addresses = addresses.concat(activity.cc);
66 |
67 | // if there is only 1 addressee, and that addressee is me, return true
68 | if (addresses.length === 1 && addresses[0] === actor.id) {
69 | return true;
70 | }
71 |
72 | return false;
73 | };
74 |
75 | export const deleteObject = async (actor, incomingRequest) => {
76 | if (typeof incomingRequest.object !== 'object') {
77 | return false;
78 | }
79 | if (incomingRequest.object.type !== 'Tombstone') {
80 | return false;
81 | }
82 | // TODO: support delete of user.
83 | // remove user, remove follow, following, etc.
84 | // remove all posts by user.
85 | try {
86 | const activity = await getActivity(incomingRequest.object.id);
87 | if (activity.attributedTo !== actor.id) {
88 | // only allow actor to delete their own Notes
89 | return false;
90 | }
91 | } catch (err) {
92 | // maybe you couldn't et it because it is a now-deleted DM. Let's make sure.
93 | // this is a DM and needs to be removed from the inbox
94 | const inbox = getInbox(actor.id);
95 | if (inbox.some(m => m.id === incomingRequest.object.id)) {
96 | const username = ActivityPub.getUsername(actor.id);
97 | const inboxPath = path.resolve(pathToDMs, `${username}.json`);
98 | // write an inbox without the deleted message
99 | writeJSONDictionary(
100 | inboxPath,
101 | inbox.filter(m => {
102 | return m.id !== incomingRequest.object.id;
103 | })
104 | );
105 | }
106 |
107 | // otherwise, if we don't know about this post, we don't need to delete it?
108 | return true;
109 | }
110 | deleteActivity(incomingRequest.object.id, incomingRequest.object);
111 | return true;
112 | };
113 |
114 | export const acceptDM = (dm, inboxUser) => {
115 | const inboxIndex = getInboxIndex();
116 | const inbox = getInbox(inboxUser);
117 | const username = ActivityPub.getUsername(inboxUser);
118 |
119 | inbox.push(dm);
120 |
121 | const inboxPath = path.resolve(pathToDMs, `${username}.json`);
122 | writeJSONDictionary(inboxPath, inbox);
123 | if (!inboxIndex[inboxUser]) {
124 | inboxIndex[inboxUser] = {
125 | lastRead: null
126 | };
127 | }
128 |
129 | const timestamp = new Date(dm.published).getTime();
130 | inboxIndex[inboxUser].latest = timestamp;
131 |
132 | // if this is me sending an outbound DM, mark last read also
133 | if (dm.attributedTo === ActivityPub.actor.id) {
134 | inboxIndex[inboxUser].lastRead = timestamp;
135 | }
136 |
137 | writeInboxIndex(inboxIndex);
138 | };
139 |
140 | export const isMyPost = activity => {
141 | return activity.id.startsWith(`https://${DOMAIN}/m/`);
142 | };
143 |
144 | export const isFollowing = actorId => {
145 | const following = getFollowing();
146 | return following.some(f => f.actorId === actorId);
147 | };
148 |
149 | export const isFollower = actorId => {
150 | const followers = getFollowers();
151 | return followers.includes(actorId);
152 | };
153 |
154 | export const isMention = activity => {
155 | return activity.tag?.some(tag => {
156 | return tag.type === 'Mention' && tag.href === ActivityPub.actor.id;
157 | });
158 | };
159 |
160 | export const isReplyToMyPost = activity => {
161 | // has inReplyTo AND it matches the pattern of our posts.
162 | // TODO: Do we need to ACTUALLY validate that this post exists?
163 | return activity.inReplyTo && activity.inReplyTo.startsWith(`https://${DOMAIN}/m/`);
164 | };
165 |
166 | export const isReplyToFollowing = async activity => {
167 | // fetch the parent, check ITs owner to see if we follow them.
168 | try {
169 | const parentPost = await getActivity(activity.inReplyTo);
170 | if (isFollowing(parentPost.attributedTo)) {
171 | return true;
172 | }
173 | } catch (err) {
174 | console.error('Could not parent post', activity.id, err);
175 | }
176 | return false;
177 | };
178 |
179 | function createActor(name, domain, pubkey) {
180 | return {
181 | '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
182 | id: `https://${domain}/u/${name}`,
183 | url: `https://${domain}/`,
184 | type: 'Person',
185 | name: `${name}`,
186 | preferredUsername: `${name}`,
187 | inbox: `https://${domain}/api/inbox`,
188 | outbox: `https://${domain}/api/outbox`,
189 | followers: `https://${domain}/u/${name}/followers`,
190 | icon: {
191 | type: 'Image',
192 | mediaType: 'image/png',
193 | url: `https://${domain}/images/avatar.png`
194 | },
195 | image: {
196 | type: 'Image',
197 | mediaType: 'image/png',
198 | url: `https://${domain}/images/header.png`
199 | },
200 | publicKey: {
201 | id: `https://${domain}/u/${name}#main-key`,
202 | owner: `https://${domain}/u/${name}`,
203 | publicKeyPem: pubkey
204 | }
205 | };
206 | }
207 |
208 | function createWebfinger(name, domain) {
209 | return {
210 | subject: `acct:${name}@${domain}`,
211 |
212 | links: [
213 | {
214 | rel: 'self',
215 | type: 'application/activity+json',
216 | href: `https://${domain}/u/${name}`
217 | }
218 | ]
219 | };
220 | }
221 |
222 | export const getOutboxPosts = async offset => {
223 | // sort all known posts by date quickly
224 | const sortedSlice = INDEX.filter(p => p.type === 'note').sort(sortByDate);
225 |
226 | const total = sortedSlice.length;
227 |
228 | const posts = await Promise.all(
229 | sortedSlice.slice(offset, offset + 10).map(async p => {
230 | return await getNote(p.id);
231 | })
232 | );
233 |
234 | return {
235 | total,
236 | posts
237 | };
238 | };
239 |
240 | export const addNotification = notification => {
241 | const notifications = getNotifications();
242 | notifications.push({
243 | time: new Date().getTime(),
244 | notification
245 | });
246 | writeNotifications(notifications);
247 | };
248 |
249 | export const writeNotifications = notifications => {
250 | return writeJSONDictionary(notificationsFile, notifications);
251 | };
252 |
253 | export const getNotifications = () => {
254 | return readJSONDictionary(notificationsFile);
255 | };
256 |
257 | // todo: expose an interface for adding to the block list.
258 | // const writeBlocks = (data) => {
259 | // return writeJSONDictionary(blocksFile, data);
260 | // }
261 |
262 | export const isBlocked = actor => {
263 | const blocks = getBlocks();
264 | return blocks.some(banned => {
265 | if (banned === actor) {
266 | console.log('BLOCKED: banned user');
267 | return true;
268 | } else if (actor.startsWith(banned)) {
269 | console.log('BLOCKED: banned domain');
270 | return true;
271 | }
272 | return false;
273 | });
274 | };
275 |
276 | export const getBlocks = () => {
277 | return readJSONDictionary(blocksFile, []);
278 | };
279 |
280 | const writeFollowers = followers => {
281 | return writeJSONDictionary(followersFile, followers);
282 | };
283 |
284 | export const getFollowers = () => {
285 | return readJSONDictionary(followersFile);
286 | };
287 |
288 | export const writeFollowing = followers => {
289 | return writeJSONDictionary(followingFile, followers);
290 | };
291 |
292 | export const getFollowing = () => {
293 | return readJSONDictionary(followingFile).map(f => {
294 | if (typeof f === 'string') {
295 | // map old format to new format just in case
296 | // TODO: remove this before release
297 | return {
298 | id: f,
299 | actorId: f
300 | };
301 | } else {
302 | return f;
303 | }
304 | });
305 | };
306 |
307 | export const writeBoosts = data => {
308 | return writeJSONDictionary(boostsFile, data);
309 | };
310 |
311 | export const getBoosts = () => {
312 | return readJSONDictionary(boostsFile, []);
313 | };
314 |
315 | export const writeLikes = likes => {
316 | return writeJSONDictionary(likesFile, likes);
317 | };
318 |
319 | export const getLikes = () => {
320 | return readJSONDictionary(likesFile);
321 | };
322 |
323 | export const getNote = async id => {
324 | // const postFile = path.resolve('./', pathToPosts, guid + '.json');
325 | const noteFile = getFileName(id);
326 |
327 | if (fs.existsSync(noteFile)) {
328 | try {
329 | return readJSONDictionary(noteFile, {});
330 | } catch (err) {
331 | console.error(err);
332 | return undefined;
333 | }
334 | }
335 | return undefined;
336 | };
337 |
338 | export const sendCreateToFollowers = async object => {
339 | const followers = await getFollowers();
340 | const actors = await Promise.all(
341 | followers.map(async follower => {
342 | try {
343 | const account = await fetchUser(follower);
344 | return account.actor;
345 | } catch (err) {
346 | console.error('Could not fetch follower', err);
347 | }
348 | })
349 | );
350 |
351 | actors.forEach(async actor => {
352 | ActivityPub.sendCreate(actor, object);
353 | });
354 | };
355 |
356 | export const sendUpdateToFollowers = async object => {
357 | const followers = await getFollowers();
358 | const actors = await Promise.all(
359 | followers.map(async follower => {
360 | try {
361 | const account = await fetchUser(follower);
362 | return account.actor;
363 | } catch (err) {
364 | console.error('Could not fetch follower', err);
365 | }
366 | })
367 | );
368 |
369 | actors.forEach(async actor => {
370 | ActivityPub.sendUpdate(actor, object);
371 | });
372 | };
373 |
374 | export const createNote = async (body, cw, inReplyTo, toUser, editOf) => {
375 | const publicAddress = 'https://www.w3.org/ns/activitystreams#Public';
376 |
377 | const d = new Date();
378 | let guid;
379 | if (editOf) {
380 | // use same guid as post we're updating
381 | guid = editOf.replace(`https://${DOMAIN}/m/`, '');
382 | } else {
383 | // generate new guid
384 | guid = crypto.randomBytes(16).toString('hex');
385 | }
386 | let directMessage;
387 |
388 | // default to public
389 | let to = [publicAddress];
390 |
391 | // default recipient is my followers
392 | let cc = [ActivityPub.actor.followers];
393 |
394 | // Contains mentions
395 | const tags = [];
396 |
397 | if (inReplyTo || toUser) {
398 | if (toUser) {
399 | // TODO: validate the to field is a legit account
400 | to = [toUser];
401 | cc = [];
402 | directMessage = true;
403 | } else {
404 | // get parent post
405 | // so we can get the author and add to TO list
406 | const parent = await getActivity(inReplyTo);
407 | if (addressedOnlyToMe(parent)) {
408 | // this is a reply to a DM
409 | // set the TO to be ONLY to them (override public)
410 | to = [parent.attributedTo];
411 | // clear the CC list
412 | cc = [];
413 | directMessage = true;
414 | } else {
415 | cc.push(parent.attributedTo);
416 | }
417 | }
418 | }
419 |
420 | // Process content in various ways...
421 | let processedContent = body;
422 |
423 | // cribbed directly from mastodon
424 | // https://github.com/mastodon/mastodon/blob/main/app/models/account.rb
425 | const MENTION_RE = /(?<=^|[^/\w])@(([a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?)(?:@[\w.-]+[\w]+)?)/gi;
426 |
427 | const mentions = body.match(MENTION_RE);
428 | if (mentions && mentions.length) {
429 | const uniqueMentions = [...new Set(mentions)];
430 | for (let m = 0; m < uniqueMentions.length; m++) {
431 | const mention = uniqueMentions[m].trim();
432 | const uname = ActivityPub.getUsername(mention);
433 | // construct a mastodon-style link
434 | // @benbrown@benbrown.ngrok.io
435 | const account = await fetchUser(uname);
436 |
437 | const link = `@${uname} `;
438 | processedContent = processedContent.replaceAll(mention, link);
439 |
440 | tags.push({
441 | type: 'Mention',
442 | href: account.actor.url,
443 | name: `@${uname}`
444 | });
445 |
446 | // if this is not a direct message to a single person,
447 | // add the mentioned person to the CC list
448 | if (!directMessage) {
449 | cc.push(account.actor.id);
450 | }
451 | }
452 | }
453 |
454 | // if this is a DM, require a mention of the recipient
455 | if (directMessage) {
456 | const account = await fetchUser(to[0]);
457 | const uname = ActivityPub.getUsername(to[0]);
458 |
459 | if (!tags.some(t => t.href === account.actor.url)) {
460 | const link = `@${uname} `;
461 | processedContent = link + ' ' + processedContent;
462 |
463 | tags.push({
464 | type: 'Mention',
465 | href: account.actor.url,
466 | name: `@${uname}`
467 | });
468 | }
469 | }
470 |
471 | const content = md.render(processedContent);
472 |
473 | const activityId = `https://${DOMAIN}/m/${guid}`;
474 | const url = `https://${DOMAIN}/notes/${guid}`;
475 | const object = {
476 | id: activityId,
477 | type: 'Note',
478 | summary: cw || null,
479 | inReplyTo,
480 | published: d.toISOString(),
481 | attributedTo: ActivityPub.actor.id,
482 | content,
483 | url,
484 | to,
485 | cc,
486 | directMessage,
487 | sensitive: !!cw,
488 | atomUri: activityId,
489 | inReplyToAtomUri: null,
490 | attachment: [],
491 | tag: tags,
492 | replies: {
493 | id: `${activityId}/replies`,
494 | type: 'Collection',
495 | first: {
496 | type: 'CollectionPage',
497 | next: `${activityId}/replies?only_other_accounts=true&page=true`,
498 | partOf: `${activityId}/replies`,
499 | items: []
500 | }
501 | }
502 | };
503 |
504 | if (editOf) {
505 | object.updated = d.toISOString();
506 | }
507 |
508 | if (directMessage) {
509 | acceptDM(object, to[0]);
510 | } else {
511 | const noteFile = createFileName(object);
512 | writeJSONDictionary(noteFile, object);
513 |
514 | const inIndex = INDEX.findIndex(idx => idx.id === object.id);
515 | if (inIndex < 0) {
516 | INDEX.push({
517 | type: 'note', // (as opposed to an activity which reps someone else)
518 | id: object.id,
519 | actor: object.attributedTo,
520 | published: new Date(object.published).getTime(),
521 | inReplyTo: object.inReplyTo
522 | });
523 | }
524 | }
525 |
526 | // process recipients
527 | to.concat(cc).forEach(async recipient => {
528 | // if the recipient is "my followers", send it to them
529 |
530 | if (recipient === publicAddress) {
531 | // do nothing
532 | } else if (recipient === ActivityPub.actor.followers) {
533 | if (editOf) {
534 | sendUpdateToFollowers(object);
535 | } else {
536 | sendCreateToFollowers(object);
537 | }
538 | } else {
539 | // otherwise, send it directly to the person
540 | const account = await fetchUser(recipient);
541 | if (editOf) {
542 | ActivityPub.sendUpdate(account.actor, object);
543 | } else {
544 | ActivityPub.sendCreate(account.actor, object);
545 | }
546 | }
547 | });
548 |
549 | return object;
550 | };
551 |
552 | export const follow = async request => {
553 | logger('following someone');
554 | const { actor } = await fetchUser(request.object.object);
555 | if (actor) {
556 | if (!isFollowing(actor.id)) {
557 | const following = getFollowing();
558 | following.push({
559 | id: request.object.id, // record the id of the original follow that was just approved
560 | actorId: actor.id
561 | });
562 | writeFollowing(following);
563 | }
564 |
565 | // fetch the user's outbox if it exists...
566 | if (actor.outbox) {
567 | logger('downloading outbox...');
568 | ActivityPub.fetchOutbox(actor).then(outbox => {
569 | logger('outbox downloaded', outbox.items.length);
570 | outbox.items.forEach(activity => {
571 | if (activity.type === 'Create') {
572 | logger('create a post', activity.object);
573 | // log the post to our activity feed
574 | if (typeof activity.object === 'string') {
575 | getActivity(activity.object)
576 | .then(newactivity => {
577 | createActivity(newactivity);
578 | })
579 | .catch(err => {
580 | console.error('Could not get outbox post', err);
581 | });
582 | } else {
583 | createActivity(activity.object);
584 | }
585 | } else if (activity.type === 'Announce') {
586 | // TODO: fetch boosted posts, etc.
587 | }
588 | });
589 | });
590 | }
591 | } else {
592 | logger('Failed to fetch user');
593 | }
594 | };
595 |
596 | export const addFollower = async request => {
597 | logger('Adding follower...');
598 | const { actor } = await fetchUser(request.actor);
599 | if (actor) {
600 | const followers = getFollowers();
601 | if (followers.indexOf(actor.id) < 0) {
602 | followers.push(actor.id);
603 | writeFollowers(followers);
604 | addNotification(request);
605 | }
606 | } else {
607 | logger('Failed to fetch user');
608 | }
609 | };
610 |
611 | export const removeFollower = async follower => {
612 | logger('Removing follower...');
613 | const { actor } = await fetchUser(follower);
614 | if (actor) {
615 | const followers = getFollowers();
616 | writeFollowers(followers.filter(f => f !== follower));
617 | } else {
618 | logger('Failed to fetch user');
619 | }
620 | };
621 |
622 | export const ensureAccount = async (name, domain) => {
623 | // verify domain name
624 | const re = /^((?:(?:(?:\w[.\-+]?)*)\w)+)((?:(?:(?:\w[.\-+]?){0,62})\w)+)\.(\w{2,6})$/;
625 | if (!domain.match(re)) {
626 | console.error('DOMAIN setting "' + domain + '" does not appear to be a well-formatted domain name.');
627 | process.exit(1);
628 | }
629 | return new Promise((resolve, reject) => {
630 | if (existsSync(accountFile)) {
631 | resolve(getAccount());
632 | } else {
633 | // generate a crypto key
634 | crypto.generateKeyPair(
635 | 'rsa',
636 | {
637 | modulusLength: 4096,
638 | publicKeyEncoding: {
639 | type: 'spki',
640 | format: 'pem'
641 | },
642 | privateKeyEncoding: {
643 | type: 'pkcs8',
644 | format: 'pem'
645 | }
646 | },
647 | (err, publicKey, privateKey) => {
648 | if (err) {
649 | console.error(err);
650 | reject(err);
651 | }
652 | const actorRecord = createActor(name, domain, publicKey);
653 | const webfingerRecord = createWebfinger(name, domain);
654 | const apikey = crypto.randomBytes(16).toString('hex');
655 |
656 | const account = {
657 | actor: actorRecord,
658 | webfinger: webfingerRecord,
659 | apikey,
660 | publicKey,
661 | privateKey
662 | };
663 |
664 | console.log('Account created! Wrote webfinger and actor record to', accountFile);
665 | writeJSONDictionary(accountFile, account);
666 | resolve(account);
667 | }
668 | );
669 | }
670 | });
671 | };
672 |
673 | export const getAccount = () => {
674 | return readJSONDictionary(accountFile, {});
675 | };
676 |
--------------------------------------------------------------------------------
/lib/notes.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import debug from 'debug';
3 | import { addNotification, isBlocked, writeNotifications, getNotifications } from './account.js';
4 | import {
5 | INDEX,
6 | readJSONDictionary,
7 | writeJSONDictionary,
8 | fromIndex,
9 | deleteActivityFromIndex,
10 | deleteJSONDictionary,
11 | addActivityToIndex,
12 | addFailureToIndex,
13 | createFileName,
14 | getFileName,
15 | getLikesFileName
16 | } from './storage.js';
17 | import { ActivityPub } from './ActivityPub.js';
18 |
19 | const logger = debug('ono:notes');
20 |
21 | export const getLikesForNote = id => {
22 | const fileName = getLikesFileName(id);
23 | return readJSONDictionary(fileName, {
24 | likes: [],
25 | boosts: []
26 | });
27 | };
28 |
29 | export const getReplyCountForNote = id => {
30 | return INDEX.filter(i => i.inReplyTo === id).length;
31 | };
32 |
33 | export const recordLike = request => {
34 | const actor = request.actor;
35 | const noteId = request.object;
36 |
37 | logger('INCOMING LIKE FOR', noteId);
38 |
39 | const likes = getLikesForNote(noteId);
40 | if (likes.likes.indexOf(actor) < 0) {
41 | likes.likes.push(actor);
42 | const fileName = getLikesFileName(noteId);
43 | writeJSONDictionary(fileName, likes);
44 | addNotification(request);
45 | }
46 | };
47 |
48 | export const recordBoost = request => {
49 | const actor = request.actor;
50 | const noteId = request.object;
51 |
52 | logger('INCOMING BOOST FOR', noteId);
53 |
54 | const likes = getLikesForNote(noteId);
55 | if (likes.boosts.indexOf(actor) < 0) {
56 | likes.boosts.push(actor);
57 | const fileName = getLikesFileName(noteId);
58 | writeJSONDictionary(fileName, likes);
59 | addNotification(request);
60 | }
61 | };
62 |
63 | export const recordUndoLike = request => {
64 | const actor = request.actor;
65 | const noteId = request.object;
66 |
67 | logger('INCOMING LIKE FOR', noteId);
68 |
69 | const likes = getLikesForNote(noteId);
70 | likes.likes = likes.likes.filter(a => a !== actor);
71 | const fileName = getLikesFileName(noteId);
72 | writeJSONDictionary(fileName, likes);
73 | };
74 |
75 | export const deleteActivity = (id, tombstone) => {
76 | const noteFile = getFileName(id);
77 | if (fs.existsSync(noteFile)) {
78 | // rather than capture a tombstone, just delete it like it never was.
79 | deleteActivityFromIndex(id);
80 | deleteJSONDictionary(noteFile);
81 |
82 | // delete any reply or mention notifications
83 | const notifications = getNotifications();
84 | writeNotifications(
85 | notifications.filter(n => {
86 | // filter only notifications that are replies or mentions
87 | if ((n.notification.type === 'Reply' || n.notification.type === 'Mention') && n.notification.object === id) {
88 | return false;
89 | }
90 | return true;
91 | })
92 | );
93 | }
94 | };
95 |
96 | export const createActivity = note => {
97 | const noteFile = createFileName(note);
98 | if (!fs.existsSync(noteFile)) {
99 | addActivityToIndex(note);
100 | }
101 | writeJSONDictionary(noteFile, note);
102 | };
103 |
104 | export const getActivity = async id => {
105 | try {
106 | if (isBlocked(id)) {
107 | throw new Error('Content is from blocked domain', id);
108 | }
109 | const indexed = fromIndex(id);
110 | if (indexed !== false) {
111 | // if is cached, no need to check for file
112 | if (indexed.type === 'fail') {
113 | // TODO: could retry after a while...
114 | throw new Error('Activity was unreachable', indexed);
115 | } else {
116 | const noteFile = getFileName(id);
117 | return readJSONDictionary(noteFile, {});
118 | }
119 | } else {
120 | return await fetchActivity(id);
121 | }
122 | } catch (err) {
123 | console.error('Failed to getActivity', err);
124 | throw err;
125 | }
126 | };
127 |
128 | const fetchActivity = async activityId => {
129 | logger('FETCH ', activityId);
130 | try {
131 | const query = await ActivityPub.fetch(activityId, {});
132 | if (query.ok) {
133 | const activity = await query.json();
134 | createActivity(activity);
135 | return activity;
136 | } else {
137 | console.error('Failed to fetch', activityId, 'REASON:', query.statusText);
138 | addFailureToIndex({
139 | id: activityId,
140 | time: new Date().getTime(),
141 | status: query.status
142 | });
143 | throw new Error('could not get post', activityId);
144 | }
145 | } catch (err) {
146 | addFailureToIndex({
147 | id: activityId,
148 | time: new Date().getTime(),
149 | status: err.message
150 | });
151 | throw new Error('could not get post', activityId);
152 | }
153 | };
154 |
--------------------------------------------------------------------------------
/lib/prefs.js:
--------------------------------------------------------------------------------
1 | export const DEFAULT_SETTINGS = {
2 | strings: {
3 | post: 'Compose'
4 | },
5 | icons: {
6 | mascot: '🚀',
7 | latest: '🤘🏼',
8 | notifications: '🍑',
9 | prefs: '⚙️',
10 | messages: '💬',
11 | post: '🆕',
12 | myPosts: '🐐',
13 | faveInactive: '☆',
14 | faveActive: '⭐️',
15 | boostInactive: '🔁',
16 | boostActive: '🚀',
17 | reply: '↩️'
18 | }
19 | };
20 |
--------------------------------------------------------------------------------
/lib/queue.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This is a VERY BASIC implementation of a queue for outgoing requests.
3 | * This separates the sending of the messages, which could take a while, require retries, etc from the response to the server or UI.
4 | */
5 |
6 | import Queue from 'queue-promise';
7 | import debug from 'debug';
8 | const logger = debug('ono:queue');
9 |
10 | export const queue = new Queue({
11 | concurrent: 4,
12 | interval: 250
13 | // concurrent: 1,
14 | // interval: 2000
15 | });
16 |
17 | queue.on('start', () => logger('QUEUE STARTING'));
18 | queue.on('stop', () => logger('QUEUE STOPPING'));
19 | queue.on('end', () => logger('QUEUE ENDING'));
20 | queue.on('dequeue', () => logger('DEQUEUING!', queue.size));
21 |
22 | queue.on('resolve', data => {
23 | if (data.url) {
24 | logger(`SEND STATUS ${data.status} ${data.statusText} FOR ${data.url} `);
25 | } else {
26 | logger(data);
27 | }
28 | });
29 | queue.on('reject', error => console.error(error));
30 |
31 | while (queue.shouldRun) {
32 | try {
33 | await queue.dequeue();
34 | } catch (err) {
35 | console.error(err);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/lib/storage.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import glob from 'glob';
3 | import path from 'path';
4 | import md5 from 'md5';
5 | import { DEFAULT_SETTINGS } from './prefs.js';
6 |
7 | import debug from 'debug';
8 |
9 | import dotenv from 'dotenv';
10 | const logger = debug('ono:storage');
11 | dotenv.config();
12 |
13 | export const dataDir = path.resolve('./', '.data/');
14 | export const pathToFiles = path.resolve(dataDir, 'activitystream/');
15 | export const pathToPosts = path.resolve(dataDir, 'posts/');
16 | export const pathToUsers = path.resolve(dataDir, 'users/');
17 | export const pathToDMs = path.resolve(dataDir, 'dms/');
18 |
19 | export const prefsFile = path.resolve(dataDir, 'prefs.json');
20 | export const followersFile = path.resolve(dataDir, 'followers.json');
21 | export const followingFile = path.resolve(dataDir, 'following.json');
22 | export const notificationsFile = path.resolve(dataDir, 'notifications.json');
23 | export const likesFile = path.resolve(dataDir, 'likes.json');
24 | export const boostsFile = path.resolve(dataDir, 'boosts.json');
25 | export const blocksFile = path.resolve(dataDir, 'blocks.json');
26 | export const accountFile = path.resolve(dataDir, 'account.json');
27 |
28 | const { DOMAIN } = process.env;
29 |
30 | export const INDEX = [];
31 | export const CACHE = {};
32 | const cacheMax = 60 * 5 * 1000; // 5 minutes
33 | const cacheMin = 30 * 1000; // 5 minutes
34 |
35 | const zeroPad = num => {
36 | if (num < 10) {
37 | return `0${num}`;
38 | } else return num;
39 | };
40 |
41 | export const isMyPost = activityId => {
42 | return activityId.startsWith(`https://${DOMAIN}/m/`);
43 | };
44 |
45 | export const isIndexed = id => {
46 | return INDEX.some(p => id === p.id);
47 | };
48 |
49 | export const fromIndex = id => {
50 | return INDEX.find(p => id === p.id) || false;
51 | };
52 |
53 | export const getPrefs = () => {
54 | return readJSONDictionary(prefsFile, DEFAULT_SETTINGS);
55 | };
56 | export const updatePrefs = prefs => {
57 | return writeJSONDictionary(prefsFile, prefs);
58 | };
59 |
60 | export const addFailureToIndex = (note, type = 'fail') => {
61 | INDEX.push({
62 | type,
63 | id: note.id,
64 | published: note.time,
65 | status: note.status
66 | });
67 | };
68 | export const addActivityToIndex = (note, type = 'activity') => {
69 | INDEX.push({
70 | type,
71 | id: note.id,
72 | actor: note.attributedTo || note.actor,
73 | published: new Date(note.published).getTime(),
74 | inReplyTo: note.inReplyTo
75 | });
76 | };
77 | export const deleteActivityFromIndex = id => {
78 | const n = INDEX.findIndex(idx => idx.id === id);
79 | if (n >= 0) {
80 | INDEX.splice(n, 1);
81 | }
82 | };
83 |
84 | export const getFileName = activityId => {
85 | // // find the item in the index
86 | // first check cache!
87 | let meta;
88 | if (CACHE[activityId]) {
89 | meta = CACHE[activityId].contents;
90 | } else {
91 | meta = INDEX.find(m => m.id === activityId);
92 | if (!meta) {
93 | console.error('id not found in index!', activityId);
94 | throw new Error('id not found in index');
95 | }
96 | }
97 |
98 | const rootPath = isMyPost(activityId) ? pathToPosts : pathToFiles;
99 |
100 | // create a dated subfolder
101 | const datestamp = new Date(meta.published);
102 | const folder = datestamp.getFullYear() + '/' + zeroPad(datestamp.getMonth() + 1) + '-' + zeroPad(datestamp.getDate());
103 | return path.resolve(rootPath, folder, `${md5(meta.id)}.json`);
104 | };
105 |
106 | export const getLikesFileName = activityId => {
107 | // // find the item in the index
108 | // first check cache!
109 | let meta;
110 | if (CACHE[activityId]) {
111 | meta = CACHE[activityId].contents;
112 | } else {
113 | meta = INDEX.find(m => m.id === activityId);
114 | if (!meta) {
115 | console.error('id not found in index!', activityId);
116 | throw new Error('id not found in index');
117 | }
118 | }
119 |
120 | const rootPath = pathToPosts;
121 |
122 | // create a dated subfolder
123 | const datestamp = new Date(meta.published);
124 | const folder = datestamp.getFullYear() + '/' + zeroPad(datestamp.getMonth() + 1) + '-' + zeroPad(datestamp.getDate());
125 | return path.resolve(rootPath, folder, `${md5(meta.id)}.likes.json`);
126 | };
127 |
128 | export const createFileName = activity => {
129 | // create a dated subfolder
130 | const datestamp = new Date(activity.published);
131 | const folder = datestamp.getFullYear() + '/' + zeroPad(datestamp.getMonth() + 1) + '-' + zeroPad(datestamp.getDate());
132 |
133 | const rootPath = isMyPost(activity.id) ? pathToPosts : pathToFiles;
134 | // ensure the subfolder is prsent
135 | if (!fs.existsSync(path.resolve(rootPath, folder))) {
136 | fs.mkdirSync(path.resolve(rootPath, folder), {
137 | recursive: true
138 | });
139 | }
140 | return path.resolve(rootPath, folder, `${md5(activity.id)}.json`);
141 | };
142 |
143 | const cacheExpire = () => {
144 | const now = new Date().getTime();
145 | for (const key in CACHE) {
146 | if (CACHE[key].lastAccess < now - cacheMin) {
147 | logger('clearing cache for', key);
148 | delete CACHE[key];
149 | }
150 | }
151 | };
152 |
153 | const garbageCollector = setInterval(() => {
154 | cacheExpire();
155 | }, cacheMin);
156 |
157 | logger('Garbage collector interval', garbageCollector);
158 |
159 | const buildIndex = () => {
160 | return new Promise((resolve, reject) => {
161 | glob(path.join(pathToFiles, '**/*.json'), async (err, files) => {
162 | if (err) {
163 | console.error(err);
164 | reject(err);
165 | }
166 | // const res = [];
167 | for (const f of files) {
168 | try {
169 | const post = JSON.parse(fs.readFileSync(path.resolve(pathToFiles, f)));
170 | addActivityToIndex(post);
171 | } catch (err) {
172 | console.error('failed to parse', f);
173 | console.error(err);
174 | }
175 | }
176 |
177 | glob(path.join(pathToPosts, '**/*.json'), async (err, files) => {
178 | if (err) {
179 | console.error(err);
180 | reject(err);
181 | }
182 |
183 | for (const f of files) {
184 | try {
185 | if (!f.includes('likes')) {
186 | const post = JSON.parse(fs.readFileSync(path.resolve(pathToFiles, f)));
187 | addActivityToIndex(post, 'note');
188 | }
189 | } catch (err) {
190 | console.error('failed to parse', f);
191 | console.error(err);
192 | }
193 | }
194 |
195 | resolve(INDEX);
196 | });
197 | });
198 | });
199 | };
200 |
201 | export const searchKnownUsers = async query => {
202 | return new Promise((resolve, reject) => {
203 | glob(path.join(pathToUsers, '**/*.json'), async (err, files) => {
204 | if (err) {
205 | console.error(err);
206 | reject(err);
207 | }
208 | const results = [];
209 | for (const f of files) {
210 | try {
211 | const user = JSON.parse(fs.readFileSync(path.resolve(pathToUsers, f)));
212 | if (
213 | user.actor?.id?.toLowerCase().includes(query) ||
214 | user.actor?.preferredUsername?.toLowerCase().includes(query) ||
215 | user.actor?.name?.toLowerCase().includes(query) ||
216 | user.actor?.url?.toLowerCase().includes(query)
217 | ) {
218 | results.push(user.actor);
219 | }
220 | } catch (err) {
221 | console.error('failed to parse', f);
222 | console.error(err);
223 | }
224 | }
225 | resolve(results);
226 | });
227 | });
228 | };
229 |
230 | const ensureDataFolder = () => {
231 | if (!fs.existsSync(path.resolve(pathToPosts))) {
232 | logger('mkdir', pathToPosts);
233 | fs.mkdirSync(path.resolve(pathToPosts), {
234 | recursive: true
235 | });
236 | }
237 | if (!fs.existsSync(path.resolve(pathToFiles))) {
238 | logger('mkdir', pathToFiles);
239 | fs.mkdirSync(path.resolve(pathToFiles), {
240 | recursive: true
241 | });
242 | }
243 | if (!fs.existsSync(path.resolve(pathToUsers))) {
244 | logger('mkdir', pathToUsers);
245 | fs.mkdirSync(path.resolve(pathToUsers), {
246 | recursive: true
247 | });
248 | }
249 | if (!fs.existsSync(path.resolve(pathToDMs))) {
250 | logger('mkdir', pathToDMs);
251 | fs.mkdirSync(path.resolve(pathToDMs), {
252 | recursive: true
253 | });
254 | }
255 | if (!fs.existsSync(path.resolve(prefsFile))) {
256 | logger('create default settings', prefsFile);
257 | writeJSONDictionary(prefsFile, DEFAULT_SETTINGS);
258 | } else {
259 | // todo: validate settings, add any missing keys with default values
260 | }
261 | };
262 |
263 | export const readJSONDictionary = (path, defaultVal = []) => {
264 | const now = new Date().getTime();
265 | if (CACHE[path] && CACHE[path].time > now - cacheMax) {
266 | logger('cache hit for', path);
267 | CACHE[path].lastAccess = now;
268 | return CACHE[path].contents;
269 | } else {
270 | logger('read from disk', path);
271 | let jsonRaw = JSON.stringify(defaultVal);
272 | if (fs.existsSync(path)) {
273 | jsonRaw = fs.readFileSync(path);
274 | }
275 | const results = JSON.parse(jsonRaw) || defaultVal;
276 | CACHE[path] = {
277 | time: now,
278 | lastAccess: now,
279 | contents: results
280 | };
281 | return results;
282 | }
283 | };
284 |
285 | export const deleteJSONDictionary = path => {
286 | fs.unlinkSync(path);
287 | delete CACHE[path];
288 | };
289 |
290 | export const writeJSONDictionary = (path, data) => {
291 | const now = new Date().getTime();
292 | logger('write cache', path);
293 | CACHE[path] = {
294 | time: now,
295 | lastAccess: now,
296 | contents: data
297 | };
298 | fs.writeFileSync(path, JSON.stringify(data, null, 2));
299 | };
300 |
301 | logger('BUILDING INDEX');
302 | ensureDataFolder();
303 | buildIndex().then(() => {
304 | logger('INDEX BUILT!');
305 | });
306 |
--------------------------------------------------------------------------------
/lib/theAlgorithm.js:
--------------------------------------------------------------------------------
1 | /**
2 | _______ __ __ _______
3 | | || | | || |
4 | |_ _|| |_| || ___|
5 | | | | || |___
6 | | | | || ___|
7 | | | | _ || |___
8 | |___| |__| |__||_______|
9 | _______ ___ _______ _______ ______ ___ _______ __ __ __ __
10 | | _ || | | || || _ | | | | || | | || |_| |
11 | | |_| || | | ___|| _ || | || | | |_ _|| |_| || |
12 | | || | | | __ | | | || |_||_ | | | | | || |
13 | | || |___ | || || |_| || __ || | | | | || |
14 | | _ || || |_| || || | | || | | | | _ || ||_|| |
15 | |__| |__||_______||_______||_______||___| |_||___| |___| |__| |__||_| |_|
16 |
17 | This file contains the functions pertaining to how Shuttlecraft creates the "latest" feed
18 |
19 | **/
20 |
21 | import debug from 'debug';
22 | import { fetchUser } from './users.js';
23 | import { getNote, getLikes, getBoosts, isReplyToMyPost, isReplyToFollowing, isFollowing } from './account.js';
24 | import { getActivity } from './notes.js';
25 | import { INDEX } from './storage.js';
26 | import { ActivityPub } from './ActivityPub.js';
27 |
28 | const logger = debug('ono:algorithm');
29 |
30 | export const sortByDate = (a, b) => {
31 | if (a.published > b.published) {
32 | return -1;
33 | } else if (a.published < b.published) {
34 | return 1;
35 | } else {
36 | return 0;
37 | }
38 | };
39 |
40 | /**
41 | * Given an activity record OR an id for an activity record, returns the full activity along with
42 | * the actor, and, if a boost, information about the boost and boosting user
43 | * @param {*} activityOrId
44 | * @returns {note, actor, boost, booster}
45 | */
46 | export const getFullPostDetails = async activityOrId => {
47 | const likes = await getLikes();
48 | const boosts = await getBoosts();
49 |
50 | let note, actor, boost, booster;
51 | try {
52 | if (typeof activityOrId === 'string') {
53 | note = await getActivity(activityOrId);
54 | } else {
55 | note = activityOrId;
56 | }
57 | } catch (err) {
58 | console.error(err);
59 | console.error('Could not load post in feed');
60 | return;
61 | }
62 |
63 | const account = await fetchUser(note.attributedTo || note.actor);
64 | actor = account.actor;
65 |
66 | if (note.type === 'Announce') {
67 | boost = note;
68 | booster = actor;
69 | try {
70 | note = await getActivity(boost.object);
71 | const op = await fetchUser(note.attributedTo);
72 | actor = op.actor;
73 | } catch (err) {
74 | console.error(err);
75 | console.error('Could not fetch boosted post...', boost.object);
76 | return;
77 | }
78 | }
79 |
80 | note.isLiked = !!likes.some(l => l.activityId === note.id);
81 | note.isBoosted = !!boosts.some(l => l.activityId === note.id);
82 |
83 | return {
84 | note,
85 | actor,
86 | boost,
87 | booster
88 | };
89 | };
90 |
91 | export const getActivityStream = async (limit, offset) => {
92 | logger('Generating activity stream...');
93 |
94 | // sort all known posts by date quickly
95 | // exclude any posts that are marked as unreachable
96 | // and also exclude posts without a published date
97 | const sortedSlice = INDEX.filter(p => p.type !== 'fail' && !isNaN(p.published)).sort(sortByDate);
98 |
99 | // res will contain the
100 | const stream = [];
101 |
102 | // iterate over the list until we get enough posts (or run out of posts)
103 | let px;
104 | for (px = offset; px < sortedSlice.length; px++) {
105 | const p = sortedSlice[px];
106 |
107 | // process a post by someone else
108 | if (p.type === 'activity') {
109 | // Ignore posts from people I am not following
110 | if (!isFollowing(p.actor)) {
111 | continue;
112 | }
113 |
114 | if (!p.inReplyTo || isReplyToMyPost(p) || (await isReplyToFollowing(p))) {
115 | try {
116 | const post = await getFullPostDetails(p.id);
117 | stream.push(post);
118 | } catch (err) {
119 | console.error('error while loading post from index');
120 | }
121 | } else {
122 | // disgard replies i don't care about
123 | }
124 | }
125 |
126 | // process a post by me
127 | if (p.type === 'note') {
128 | const post = await getFullPostDetails(p.id);
129 | stream.push(post);
130 | }
131 |
132 | // if we have enough posts, break out of the loop
133 | if (stream.length === limit) {
134 | break;
135 | }
136 | }
137 |
138 | return {
139 | activitystream: stream,
140 | next: px
141 | };
142 | };
143 |
144 | export const getActivitySince = async (since, excludeSelf = false) => {
145 | // sort all known posts by date quickly
146 | const sortedSlice = INDEX.filter(p => p.type !== 'fail' && !isNaN(p.published))
147 | .sort(sortByDate)
148 | .filter(p => {
149 | if (excludeSelf && p.actor === ActivityPub.actor.id) {
150 | return false;
151 | }
152 | return p.published > since;
153 | });
154 |
155 | const res = [];
156 | let px;
157 | for (px = 0; px < sortedSlice.length; px++) {
158 | const p = sortedSlice[px];
159 | if (p.type === 'activity') {
160 | if (isFollowing(p.actor)) {
161 | if (!p.inReplyTo || isReplyToMyPost(p) || (await isReplyToFollowing(p))) {
162 | try {
163 | const { actor } = await fetchUser(p.actor);
164 | const post = await getActivity(p.id);
165 | res.push({
166 | note: post,
167 | actor
168 | });
169 | } catch (err) {
170 | console.error('error while loading post from index');
171 | }
172 | } else {
173 | // disgard replies i don't care about
174 | }
175 | } else {
176 | // disregard not from following
177 | }
178 | } else {
179 | const post = await getNote(p.id);
180 | res.push({
181 | note: post,
182 | actor: ActivityPub.actor
183 | });
184 | }
185 | }
186 |
187 | return {
188 | activitystream: res
189 | };
190 | };
191 |
--------------------------------------------------------------------------------
/lib/users.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs';
3 | import debug from 'debug';
4 |
5 | import { readJSONDictionary, writeJSONDictionary, pathToUsers } from './storage.js';
6 | import { ActivityPub } from './ActivityPub.js';
7 | const logger = debug('ono:users');
8 |
9 | const fetchUserFromSource = async (username, webId) => {
10 | let webfinger;
11 |
12 | if (!webId) {
13 | try {
14 | webfinger = await ActivityPub.webfinger(username);
15 | } catch (err) {
16 | // console.error(err); // servers receive lots of spam Deletes
17 | return {
18 | actor: {
19 | name: username,
20 | preferredUsername: username
21 | }
22 | };
23 | }
24 | }
25 |
26 | // now fetch actor info
27 | const self = webId || webfinger.links.filter(l => l.rel === 'self')[0]?.href;
28 | let actor;
29 | if (self) {
30 | logger(`fetch activitypub.actor ${self}`);
31 | try {
32 | actor = await ActivityPub.fetchActor(self);
33 | } catch (err) {
34 | // console.error(err);
35 | return {
36 | actor: {
37 | name: username,
38 | preferredUsername: username,
39 | id: webId
40 | }
41 | };
42 | }
43 | } else {
44 | throw new Error('could not find self link in webfinger');
45 | }
46 |
47 | const userFile = path.resolve(pathToUsers, `${username}.json`);
48 | logger(`update ${userFile}`);
49 | writeJSONDictionary(userFile, {
50 | webfinger,
51 | actor,
52 | lastFetched: new Date().getTime()
53 | });
54 |
55 | return {
56 | webfinger,
57 | actor,
58 | lastFetched: new Date().getTime()
59 | };
60 | };
61 |
62 | export const fetchUser = async user => {
63 | let skipFinger = false;
64 | const now = new Date().getTime();
65 | const cacheMax = 1 * 60 * 60 * 1000; // cache user info for 1 hour
66 |
67 | const username = ActivityPub.getUsername(user);
68 | // if we start with an activitypub url, we don't need to finger to get it
69 | if (user.startsWith('https://')) {
70 | skipFinger = true;
71 | }
72 |
73 | const userFile = path.resolve(pathToUsers, `${username}.json`);
74 | logger('load user', user, userFile);
75 |
76 | if (fs.existsSync(userFile)) {
77 | const account = readJSONDictionary(userFile);
78 | // check date to see if we need to refetch...
79 | if (account.lastFetched && account.lastFetched > now - cacheMax) {
80 | return account;
81 | } else if (!account.actor || !account.actor.id) {
82 | // do nothing and fall through to the live fetch
83 | // since we don't have a full user account here
84 | } else {
85 | logger('fetch fresh user for', user, `${username}`);
86 | // attempt to fetch a new one async
87 | // TODO: needs to be debounced - could try to load same user many times quickly
88 | fetchUserFromSource(username, account?.actor?.id).catch(err =>
89 | console.error('Error updating user data for', username, err)
90 | );
91 | return account;
92 | }
93 | }
94 |
95 | return await fetchUserFromSource(username, skipFinger ? user : null);
96 | };
97 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "shuttlecraft",
3 | "version": "0.0.1",
4 | "description": "a single user activitypub server - join the federation!",
5 | "keywords": [
6 | "fediverse",
7 | "mastodon",
8 | "socialmedia",
9 | "indieweb",
10 | "benbrown"
11 | ],
12 | "homepage": "https://shuttlecraft.net",
13 | "bugs": {
14 | "url": "https://github.com/benbrown/shuttlecraft/issues"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "https://github.com/benbrown/shuttlecraft"
19 | },
20 | "license": "MIT",
21 | "author": "ben brown ",
22 | "type": "module",
23 | "main": "index.js",
24 | "scripts": {
25 | "prepare": "husky install",
26 | "start": "node index.js",
27 | "test": "echo \"Error: no test specified\" && exit 1",
28 | "postinstall": "copy-env-cli",
29 | "lint": "eslint lib/. && prettier --check lib/.",
30 | "lint:fix": "eslint lib/. --fix && prettier --write lib/."
31 | },
32 | "engines": {
33 | "node": "16.x"
34 | },
35 | "dependencies": {
36 | "body-parser": "^1.18.3",
37 | "cookie-parser": "^1.4.6",
38 | "copy-env-cli": "^1.0.0",
39 | "cors": "^2.8.4",
40 | "debug": "^4.3.4",
41 | "dotenv": "^16.0.3",
42 | "express": "^4.16.3",
43 | "express-basic-auth": "^1.1.5",
44 | "express-handlebars": "^6.0.6",
45 | "glob": "^8.0.3",
46 | "husky": "^8.0.3",
47 | "markdown-it": "^13.0.1",
48 | "md5": "^2.3.0",
49 | "moment": "^2.29.4",
50 | "node-fetch": "^3.3.0",
51 | "queue-promise": "^2.2.1",
52 | "rss-generator": "^0.0.3"
53 | },
54 | "devDependencies": {
55 | "eslint": "^8.32.0",
56 | "eslint-config-prettier": "^8.6.0",
57 | "eslint-config-standard": "^17.0.0",
58 | "eslint-plugin-import": "^2.27.5",
59 | "eslint-plugin-n": "^15.6.1",
60 | "eslint-plugin-promise": "^6.1.1",
61 | "lint-staged": "^13.1.0",
62 | "prettier": "^2.8.3"
63 | },
64 | "lint-staged": {
65 | "*.js": [
66 | "eslint --fix",
67 | "prettier --write"
68 | ]
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/public/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benbrown/shuttlecraft/ef489a33c92ed1857509ed57d09fa55fd7811da3/public/.DS_Store
--------------------------------------------------------------------------------
/public/app.js:
--------------------------------------------------------------------------------
1 | const fetch = (url, type, payload = undefined) => {
2 | return new Promise((resolve, reject) => {
3 | const Http = new XMLHttpRequest();
4 | Http.open(type, url);
5 | // TODO: should be a parameter
6 | Http.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
7 | Http.send(payload);
8 |
9 | Http.onreadystatechange = () => {
10 | if (Http.readyState === 4 && Http.status === 200) {
11 | resolve(Http.responseText);
12 | } else if (Http.readyState === 4 && Http.status >= 300) {
13 | reject(Http.statusText);
14 | }
15 | };
16 | });
17 | };
18 |
19 | const setCookie = (name, value, days) => {
20 | let expires = '';
21 | if (days) {
22 | const date = new Date();
23 | date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
24 | expires = '; expires=' + date.toUTCString();
25 | }
26 | document.cookie = name + '=' + (value || '') + expires + '; path=/';
27 | };
28 | // const getCookie = (name) => {
29 | // var nameEQ = name + "=";
30 | // var ca = document.cookie.split(';');
31 | // for(var i=0;i < ca.length;i++) {
32 | // var c = ca[i];
33 | // while (c.charAt(0)==' ') c = c.substring(1,c.length);
34 | // if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
35 | // }
36 | // return null;
37 | // }
38 |
39 | const app = {
40 | newPosts: 0,
41 | newNotifications: 0,
42 | newDMs: 0,
43 | latestPost: date => {
44 | setCookie('latestPost', date, 7);
45 | },
46 | latestNotification: date => {
47 | setCookie('latestNotification', date, 7);
48 | },
49 | toggleCW: id => {
50 | if (document.getElementById(id).classList.contains('collapsed')) {
51 | document.getElementById(id).classList.remove('collapsed');
52 | } else {
53 | document.getElementById(id).classList.add('collapsed');
54 | }
55 | },
56 | alertNewPosts: meta => {
57 | const newPosts = document.getElementById('newPosts')
58 | ? [document.getElementById('newPosts')]
59 | : Array.from(document.getElementsByClassName('newPostsBadge'));
60 | if (newPosts) {
61 | if (meta.newPosts > 0) {
62 | if (meta.newPosts > app.newPosts) {
63 | // BEEP!
64 | console.log('BEEP!');
65 | }
66 | app.newPosts = meta.newPosts;
67 | newPosts.forEach(badge => {
68 | badge.innerHTML = `${meta.newPosts} unread `;
69 | badge.hidden = false;
70 | });
71 | } else {
72 | newPosts.forEach(badge => {
73 | badge.innerHTML = '';
74 | badge.hidden = true;
75 | });
76 | }
77 | }
78 | const newNotifications = document.getElementById('newNotifications')
79 | ? [document.getElementById('newNotifications')]
80 | : Array.from(document.getElementsByClassName('newNotificationsBadge'));
81 | if (newNotifications) {
82 | if (meta.newNotifications > 0) {
83 | if (meta.newNotifications > app.newNotifications) {
84 | // BEEP!
85 | console.log('BEEP!');
86 | }
87 | app.newNotifications = meta.newNotifications;
88 | newNotifications.forEach(badge => {
89 | badge.innerHTML = `${meta.newNotifications} unread `;
90 | badge.hidden = false;
91 | });
92 | } else {
93 | newNotifications.forEach(badge => {
94 | badge.innerHTML = '';
95 | badge.hidden = true;
96 | });
97 | }
98 | }
99 | const newDMs = document.getElementById('newDMs')
100 | ? [document.getElementById('newDMs')]
101 | : Array.from(document.getElementsByClassName('newDMsBadge'));
102 | if (newDMs) {
103 | if (meta.newDMs > 0) {
104 | if (meta.newDMs > app.newDMs) {
105 | // BEEP!
106 | console.log('BEEP!');
107 | }
108 | app.newDMs = meta.newDMs;
109 | newDMs.forEach(badge => {
110 | badge.innerHTML = `${meta.newDMs} unread `;
111 | badge.hidden = false;
112 | });
113 | } else {
114 | newDMs.forEach(badge => {
115 | badge.innerHTML = '';
116 | badge.hidden = true;
117 | });
118 | }
119 | }
120 | },
121 | pollForPosts: () => {
122 | fetch('/private/poll', 'get')
123 | .then(json => {
124 | const res = JSON.parse(json);
125 | app.alertNewPosts(res);
126 | setTimeout(() => app.pollForPosts(), 5000); // poll every 5 seconds
127 | })
128 | .catch(err => {
129 | console.error(err);
130 | });
131 | },
132 | toggleBoost: (el, postId) => {
133 | if (el.classList.contains('busy')) return;
134 |
135 | el.classList.add('busy');
136 | fetch(
137 | '/private/boost',
138 | 'POST',
139 | JSON.stringify({
140 | post: postId
141 | })
142 | )
143 | .then(resRaw => {
144 | el.classList.remove('busy');
145 | const res = JSON.parse(resRaw);
146 | if (res.isBoosted) {
147 | console.log('boosted!');
148 | el.classList.add('active');
149 | } else {
150 | console.log('unboosted');
151 | el.classList.remove('active');
152 | }
153 | })
154 | .catch(err => {
155 | console.error(err);
156 | el.classList.remove('busy');
157 | });
158 | return false;
159 | },
160 | toggleLike: (el, postId) => {
161 | if (el.classList.contains('busy')) return;
162 |
163 | el.classList.add('busy');
164 |
165 | fetch(
166 | '/private/like',
167 | 'POST',
168 | JSON.stringify({
169 | post: postId
170 | })
171 | )
172 | .then(resRaw => {
173 | el.classList.remove('busy');
174 | const res = JSON.parse(resRaw);
175 | if (res.isLiked) {
176 | console.log('liked!');
177 | el.classList.add('active');
178 | } else {
179 | console.log('unliked');
180 | el.classList.remove('active');
181 | }
182 | })
183 | .catch(err => {
184 | console.error(err);
185 | el.classList.remove('busy');
186 | });
187 | return false;
188 | },
189 | editPost: postId => {
190 | console.log('EDIT POST', postId);
191 | window.location = '/private/post?edit=' + encodeURIComponent(postId);
192 | },
193 | post: () => {
194 | const post = document.getElementById('post');
195 | const cw = document.getElementById('cw');
196 | const inReplyTo = document.getElementById('inReplyTo');
197 | const to = document.getElementById('to');
198 | const editOf = document.getElementById('editOf');
199 |
200 | const form = document.getElementById('composer_form');
201 |
202 | form.disabled = true;
203 |
204 | fetch(
205 | '/private/post',
206 | 'POST',
207 | JSON.stringify({
208 | post: post.value,
209 | cw: cw.value,
210 | inReplyTo: inReplyTo.value,
211 | to: to.value,
212 | editOf: editOf ? editOf.value : null
213 | })
214 | )
215 | .then(newHtml => {
216 | // prepend the new post
217 | const el = document.getElementById('home_stream') || document.getElementById('inbox_stream');
218 |
219 | if (!el) {
220 | window.location = '/private/';
221 | } else {
222 | form.disabled = false;
223 | }
224 |
225 | el.innerHTML = newHtml + el.innerHTML;
226 |
227 | // reset the inputs to blank
228 | post.value = '';
229 | cw.value = '';
230 | })
231 | .catch(err => {
232 | console.error(err);
233 | });
234 | return false;
235 | },
236 | replyTo: (activityId, mention) => {
237 | window.location = '/private/post?inReplyTo=' + activityId;
238 | },
239 | toggleFollow: (el, userId) => {
240 | if (el.classList.contains('busy')) return;
241 |
242 | el.classList.add('busy');
243 | fetch(
244 | '/private/follow',
245 | 'POST',
246 | JSON.stringify({
247 | handle: userId
248 | })
249 | )
250 | .then(resRaw => {
251 | el.classList.remove('busy');
252 |
253 | console.log('followed!');
254 | const res = JSON.parse(resRaw);
255 |
256 | if (res.isFollowed) {
257 | console.log('followed!');
258 | el.classList.add('active');
259 | } else {
260 | console.log('unfollowed');
261 | el.classList.remove('active');
262 | }
263 | })
264 | .catch(err => {
265 | console.error(err);
266 | el.classList.remove('busy');
267 | });
268 | return false;
269 | },
270 | loadMoreFeeds: () => {
271 | const el = document.getElementById('top_feeds');
272 | fetch('/private/morefeeds', 'GET', null).then(newHTML => {
273 | const morelink = el.childNodes[el.childNodes.length - 1];
274 | morelink.remove();
275 | el.innerHTML = el.innerHTML + newHTML;
276 | });
277 | return false;
278 | },
279 | showMenu: () => {
280 | document.getElementById('menu').classList.toggle('active');
281 | return false;
282 | },
283 | lookup: () => {
284 | const follow = document.getElementById('lookup');
285 | const results = document.getElementById('lookup_results');
286 |
287 | console.log('Lookup user', follow.value);
288 | fetch('/private/lookup?handle=' + encodeURIComponent(follow.value), 'GET', null).then(newHTML => {
289 | results.innerHTML = newHTML;
290 | });
291 | return false;
292 | }
293 | };
294 |
--------------------------------------------------------------------------------
/public/css/main.css:
--------------------------------------------------------------------------------
1 | body {
2 | background: #F0F0f0;
3 | color: #333;
4 | font-size: 18px;
5 | }
6 |
7 | #wrapper {
8 | display: flex;
9 | width: 100%;
10 | flex-direction: row;
11 | gap: 0;
12 | }
13 |
14 | #main {
15 | height: 100vh;
16 | overflow-y: scroll;
17 | flex-grow: 1;
18 | padding-bottom: 100px;
19 | }
20 |
21 | #content {
22 | display: flex;
23 | flex-direction: column;
24 | gap: 2rem;
25 | }
26 |
27 |
28 | a { color: #8c8dff; }
29 |
30 | .stream {
31 | width: 100%;
32 | margin: 0px auto;
33 | max-width: 40rem;
34 | text-overflow: ellipsis;
35 | overflow-x: hidden;
36 | }
37 |
38 | .activity {
39 | border-top: 1px solid #393f4f;
40 | background: #FFF;
41 | padding: 1rem;
42 | }
43 |
44 | .activity .header {
45 | display: flex;
46 | flex-direction: row;
47 | gap: 1rem;
48 | }
49 |
50 | .activity .header .avatar {
51 | height: 50px;
52 | width: 50px;
53 | }
54 |
55 | .activity .attachment img {
56 | max-width: 100%;
57 | }
58 |
59 |
60 | .follow_box {
61 | padding: 1rem;
62 | }
63 | .follow_box p {
64 | margin: 0;
65 | }
66 |
67 |
68 |
69 |
70 | .profileHeader {
71 | height: 150px;
72 | /* background: #111122; */
73 | }
74 |
75 | .profileHeader img {
76 | object-fit: cover;
77 | height: 150px;
78 | width: 100%;
79 | }
80 |
81 | .profile {
82 | /* background: #333344; */
83 | }
84 |
85 |
86 | .profileToolbar {
87 | padding: 1rem;
88 | display: flex;
89 | }
90 |
91 | .profileToolbar .avatarLink {
92 | flex-grow: 0;
93 | margin-top: calc(-50px - 1rem);
94 | }
95 |
96 | .profileToolbar .avatar {
97 | width: 110px;
98 | height: 110px;
99 | border: 5px solid #333344;
100 | }
101 |
102 | .profileToolbar .tools {
103 | text-align: right;
104 | flex-grow: 1;
105 | }
106 |
107 |
108 | .profileBody {
109 | padding: 0 1rem 1rem;
110 | }
111 |
112 | .profileBody .author {
113 | font-size: 1.25rem;
114 | margin-bottom: 0.25rem;
115 | }
116 |
117 |
--------------------------------------------------------------------------------
/public/css/secret.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --background: #ccc;
3 | --text: #333333;
4 | --link: #00018c;
5 | --secondary: #ccc;
6 | --tertiary: #F0f0f0;
7 | --contrast: #414141;
8 | --separator: #7777bb;
9 | --shade: rgba(255,255,255,0.25);
10 | --highlight: rgba(0,0,0,0.25);
11 | --accent: rgb(250, 4, 197);
12 | }
13 |
14 | @media (prefers-color-scheme: dark) {
15 | :root {
16 | --background: #222233;
17 | --text: #ffffff;
18 | --link: #8c8dff;
19 | --secondary: #444455;
20 | --tertiary: #333344;
21 | --contrast: #C0C0C0;
22 | --separator: #111122;
23 | --shade: rgba(0,0,0,0.25);
24 | --highlight: rgba(255,255,255,0.25);
25 | --accent: rgb(250, 4, 197);
26 | }
27 | }
28 |
29 | body {
30 | background: var(--background);
31 | color: var(--text);
32 | font-size: 15px;
33 | padding: 0;
34 | margin: 0;
35 | font-family: Arial, Helvetica, sans-serif;
36 | }
37 |
38 | * {
39 | box-sizing: border-box;
40 | }
41 |
42 | fieldset {
43 | padding: 0;
44 | margin: 0;
45 | border: none;
46 | }
47 |
48 | .toolbar {
49 | position: fixed;
50 | bottom: 0;
51 | left: 0;
52 | right: 0;
53 | border-top: 1px solid var(--secondary);
54 | font-size: 1.5rem;
55 | background: var(--tertiary);
56 | }
57 |
58 | .toolbar ul {
59 | display: flex;
60 | flex-direction: row;
61 | }
62 |
63 | .toolbar ul li {
64 | display: inline-block;
65 | text-align: center;
66 | flex-grow: 1;
67 | position: relative;
68 | padding: 0;
69 | margin: 0;
70 | border-right: 1px solid var(--secondary);
71 | }
72 |
73 | .badge {
74 | position: absolute;
75 | right: calc(50% - 1.35rem);
76 | top: 0.25rem;
77 | font-size: 0.8rem;
78 | border-radius: 20px;
79 | padding: 0.1rem 0.25rem;
80 | background: var(--accent);
81 | }
82 | .badge span {
83 | display: none;
84 | }
85 |
86 | .toolbar ul {
87 | list-style-type: none;
88 | padding: 0;
89 | margin: 0;
90 | }
91 |
92 | .toolbar ul li .label {
93 | display: none;
94 | }
95 |
96 | .toolbar ul li a {
97 | display: block;
98 | padding: 0.5rem 2rem 1rem;
99 | color: var(--text);
100 | text-decoration: none;
101 | letter-spacing: 0.04rem;
102 |
103 | }
104 |
105 | #main {
106 | padding-bottom: 60px;
107 | }
108 |
109 |
110 | nav ul {
111 | list-style-type: none;
112 | margin: 0;
113 | padding: 0;
114 | }
115 |
116 |
117 | #nav {
118 | /* display: none; */
119 | position: fixed;
120 | bottom: 0;
121 | left: 0;
122 | right: 0;
123 |
124 | border-top: 1px solid var(--secondary);
125 | font-size: 1.5rem;
126 | background: var(--tertiary);
127 |
128 | }
129 |
130 |
131 | #nav ul {
132 | display: flex;
133 | flex-direction: row;
134 | }
135 |
136 | #nav ul li {
137 | display: inline-block;
138 | text-align: center;
139 | flex-grow: 1;
140 | position: relative;
141 | padding: 0;
142 | margin: 0;
143 | border-right: 1px solid var(--secondary);
144 | }
145 |
146 | #nav ul li a {
147 | display: block;
148 | text-decoration: none;
149 | padding: 0.5rem 2rem 1rem;
150 | }
151 |
152 | #nav ul li#logo { display: none; }
153 |
154 | #menu {
155 | width: 90vw;
156 | max-width: 300px;
157 | position: fixed;
158 | top: 0;
159 | left: -100%;
160 | bottom: 0;
161 | transition: left 0.25s;
162 | box-shadow: 10px 0px 5px rgba(0,0,0,0.25);
163 |
164 | overflow-y: auto;
165 | background: var(--tertiary);
166 | border-right: 1px solid var(--separator);
167 | flex-grow: 0;
168 | font-size: 14px;
169 | letter-spacing: 0.04rem;
170 | padding-bottom: 2rem;
171 | }
172 |
173 | #menu.active {
174 | left: 0;
175 | }
176 |
177 | #menu form {
178 | padding: 0.5rem 1rem;
179 | }
180 |
181 | #menu input {
182 | width: 100%;
183 | }
184 |
185 | #menu a {
186 | color: var(--text);
187 | text-decoration: none;
188 |
189 | padding: 0.5rem 1rem;
190 | display: block;
191 |
192 | }
193 |
194 | #menu a:hover {
195 | background: var(--highlight);
196 | }
197 |
198 | #content {
199 | padding-bottom: 3rem;
200 | }
201 |
202 | #content.nonav {
203 | padding-bottom: 0;
204 | }
205 |
206 | #top_nav {
207 | margin-top: 1rem;
208 | display: none;
209 | }
210 |
211 | #close_menu {
212 | background: none;
213 | border: 0;
214 | padding: 1rem;
215 | width: 100%;
216 | text-align: right;
217 | }
218 |
219 | #top_feeds {
220 | margin-top: 1rem;
221 | font-size: 0.75rem;
222 | }
223 |
224 | #top_nav li {
225 | position: relative;
226 | }
227 |
228 | #menu li.current a {
229 | background-color: var(--secondary);
230 | }
231 | #top_feeds li a {
232 | display: flex;
233 | align-items: center;
234 | }
235 | #top_feeds li a img {
236 | width: 25px;
237 | height: 25px;
238 | margin-right: 1rem;
239 | }
240 |
241 |
242 | @media screen and (min-width: 769px) {
243 |
244 | #wrapper {
245 | height: 100vh;
246 | display: flex;
247 | flex-direction: row;
248 | }
249 |
250 | #close_menu {
251 | display: none;
252 | }
253 |
254 | #nav {
255 | width: 50px;
256 | overflow-y: auto;
257 | background: var(--secondary);
258 | border-right: 1px solid var(--separator);
259 | position: inherit;
260 | flex-shrink: 0;
261 | }
262 |
263 |
264 | #nav ul li {
265 | display: none;
266 | }
267 | #nav ul li a {
268 | padding: 0;
269 | }
270 | #nav ul li#logo { display: block; text-align: center; padding-top: 1rem; }
271 |
272 |
273 | #menu {
274 | position: inherit;
275 | box-shadow: none;
276 | }
277 |
278 |
279 | #top_nav {
280 | display: block;
281 | }
282 |
283 |
284 | #top_feeds {
285 | margin-top: 2rem;
286 | font-size: 0.75rem;
287 | }
288 |
289 |
290 | #content {
291 | flex-grow: 1;
292 | flex-shrink: 1;
293 | overflow-y: auto;
294 | }
295 |
296 | #content .stream {
297 | margin: 1rem;
298 | }
299 |
300 |
301 | .toolbar {
302 | position: relative;
303 | height: 100vh;
304 | flex-shrink: 0;
305 | border-right: 1px solid var(--secondary);
306 | border-top: none;
307 | font-size: 2rem;
308 | background: var(--tertiary);
309 | }
310 |
311 | .toolbar ul {
312 | display: block;
313 | }
314 |
315 | .toolbar ul li {
316 | text-align: left;
317 | display: block;
318 | position: relative;
319 | border-right: none;
320 | border-bottom: 1px solid var(--secondary);
321 | }
322 |
323 |
324 | .badge {
325 | position: absolute;
326 | right: 0.25rem;
327 | top: 0.25rem;
328 | font-size: 0.8rem;
329 | border-radius: 20px;
330 | padding: 0.25rem 0.5rem;
331 | background: var(--shade);
332 | }
333 | .badge span {
334 | display: none;
335 | }
336 |
337 | .toolbar ul li .label {
338 | display: inline;
339 | }
340 |
341 | .toolbar {
342 | font-size: 0.9rem;
343 | }
344 |
345 | .toolbar ul li a {
346 | padding: 1rem 0.5rem;
347 | padding-right: 2rem;
348 | color: var(--text);
349 | text-decoration: none;
350 | letter-spacing: 0.04rem;
351 | }
352 | }
353 |
354 | .toolbar ul li a:hover {
355 | background: var(--highlight);
356 | }
357 |
358 | #header {
359 | width: 100%;
360 | font-family: 'Courier New', Courier, monospace;
361 | padding: 0.5rem;
362 | background: var(--separator);
363 | position: relative;
364 | }
365 |
366 | #header nav {
367 | position: absolute;
368 | right: 2rem;
369 | top: 0.5rem;
370 | }
371 |
372 | #main {
373 | height: 100vh;
374 | overflow-y: scroll;
375 | flex-grow: 1;
376 | }
377 |
378 | a { color: var(--link); }
379 |
380 | .stream {
381 | width: 100%;
382 | max-width: 100%;
383 | text-overflow: ellipsis;
384 | overflow-x: hidden;
385 | }
386 |
387 | @media screen and (min-width: 769px) {
388 |
389 | .stream {
390 | max-width: 40rem;
391 | }
392 |
393 | .box {
394 | max-width: 40rem;
395 | }
396 |
397 | }
398 |
399 | header {
400 | color: var(--text);
401 | font-weight: bold;
402 | padding: 0.5rem 1rem;
403 | background: var(--separator);
404 | }
405 |
406 | header.back {
407 | display: flex;
408 | align-items: center;
409 | gap: 1rem;
410 | }
411 |
412 | header .unread {
413 | float: right;
414 | font-size: 0.8rem;
415 | border-radius: 5px;
416 | padding: 0.25rem 0.5rem;
417 | background: var(--highlight);
418 | text-decoration: none;
419 | color: var(--text);
420 | }
421 |
422 | header a {
423 | text-decoration: none;
424 | }
425 |
426 | .empty {
427 | padding: 1rem;
428 | color: var(--highlight);
429 | }
430 |
431 |
432 | .activity {
433 | border-bottom: 1px solid var(--secondary);
434 | background: var(--tertiary);
435 | padding: 1rem 1rem;
436 | line-height: 1.25rem;
437 | }
438 |
439 | .byline {
440 | display: flex;
441 | flex-direction: row;
442 | align-items: center;
443 | gap: 1rem;
444 | }
445 |
446 | .author {
447 | font-weight: bold;
448 | color: var(--text);
449 | text-decoration: none;
450 | display: block;
451 | }
452 |
453 | .handle {
454 | color: var(--contrast);
455 | }
456 |
457 |
458 | .personCard {
459 | display: flex;
460 | flex-direction: row;
461 | align-items: start;
462 | gap: 1rem;
463 | }
464 |
465 | .personCard .profile {
466 | flex-grow: 1;
467 | }
468 |
469 | .personCard .author {
470 | font-weight: bold;
471 | color: var(--text);
472 | text-decoration: none;
473 | display: block;
474 | }
475 |
476 | .personCard .handle {
477 | color: var(--contrast);
478 | }
479 |
480 | .personCard .tools {
481 | flex-shrink: 0;
482 | flex-grow: 0;
483 | }
484 |
485 | #lookup_results .personCard {
486 | gap: 0;
487 | }
488 |
489 | .activity .content {
490 | padding-left: calc(50px + 1rem);
491 | }
492 |
493 | .content_warning {
494 | background: var(--shade);
495 | padding: 0.5rem 1rem;
496 | display: flex;
497 | }
498 |
499 | .content_warning .tools {
500 | flex-grow: 1;
501 | text-align: right;
502 | /* padding-right: 1rem; */
503 | }
504 |
505 | .content_warning .tools a {
506 | margin: 0px auto;
507 | padding: 0.25rem 1rem;
508 | border-radius: 4px;
509 | background: var(--shade);
510 | text-decoration: none;
511 | }
512 |
513 | @media screen and (min-width: 769px) {
514 | .activity .content {
515 | padding-left: 0;
516 | }
517 | .activity .content_warning {
518 | padding-left: 0.5rem;
519 | }
520 | }
521 |
522 | .activity .collapsed{
523 | display: none;
524 | }
525 |
526 | .activity .boost {
527 | background: var(--shade);
528 | padding: 0.25rem 0.5rem;
529 | margin-bottom: 1rem;
530 | }
531 |
532 | .avatar {
533 | height: 50px;
534 | width: 50px;
535 | }
536 |
537 | .activity footer {
538 | display: flex;
539 | flex-direction: row;
540 | align-items: center;
541 | }
542 |
543 | .permalink {
544 | color: var(--secondary);
545 | text-decoration: none;
546 | }
547 |
548 | .permalink:hover {
549 | color: var(--link);
550 | text-decoration: underline;
551 | }
552 |
553 | .activity .attachment {
554 | background: var(--shade);
555 | }
556 |
557 | .activity .attachment img,
558 | .activity .attachment video {
559 |
560 | margin: 0px auto;
561 | width: 100%;
562 | height: 200px;
563 | object-fit: contain;
564 | }
565 |
566 | #composer {
567 | padding: 1rem;
568 | }
569 |
570 | @media screen and (min-width: 769px) {
571 | #composer {
572 | padding: 0;
573 | margin-top: 1rem;
574 | }
575 | }
576 |
577 | #composer.mini {
578 | margin-bottom: 0;
579 | background-color: var(--tertiary);
580 | border-bottom: 1px solid var(--secondary);
581 | position: fixed;
582 | bottom: 50px;
583 | left: 0;
584 | width: 100%;
585 | }
586 | #composer.mini fieldset {
587 | display: flex;
588 | }
589 |
590 |
591 | #composer.mini #post {
592 | flex-grow: 1;
593 | }
594 |
595 | #composer.mini #submit {
596 | border-radius: 0;
597 | }
598 |
599 |
600 | textarea#post {
601 | width: 100%;
602 | height: 5rem;
603 | margin-bottom: 0.5rem;
604 | }
605 | input#cw {
606 | width: 100%;
607 | margin-bottom: 0.5rem;
608 | }
609 |
610 | #submit {
611 | padding: 0.5rem 1rem;
612 | background: #0cc13f;
613 | color: var(--text);
614 | border: none;
615 | border-radius: 5px;
616 | float: right;
617 |
618 | }
619 |
620 | .content .tools { flex-grow: 1; }
621 | .content .tools div { display: inline-block; }
622 | .content .tools button { font-size: 1rem; background: none; border: none; padding: 0; margin-right: 0.5rem; }
623 | /* .content .tools button.active { background: var(--highlight); } */
624 | .content .tools button .active { display: none; }
625 | .content .tools button.active .active { display: block; }
626 | .content .tools button.active .inactive { display: none; }
627 |
628 | button.follow { background: none; border: none; }
629 | button.follow .active { display: none; }
630 | button.follow.active .active { display: block; }
631 |
632 | button.follow.active .inactive { display: none; }
633 |
634 | button.bigfollow {
635 | background: #0cc13f;
636 | color: var(--text);
637 | border: none;
638 | border-radius: 5px;
639 | padding: 0.5rem 1rem;
640 | }
641 | button.bigfollow .active { display: none; }
642 | button.bigfollow.active .active { display: block; }
643 | button.bigfollow.active .inactive { display: none; }
644 |
645 |
646 |
647 | @media screen and (min-width: 769px) {
648 | .content .tools button { font-size: 1.5rem; }
649 | }
650 |
651 | .notification {
652 | margin-bottom: 1rem;
653 | }
654 |
655 | .notification a {
656 | color: var(--contrast);
657 | }
658 |
659 | .preview {
660 | padding-left: 1rem;
661 | color: var(--contrast);
662 | }
663 |
664 | .Follow .preview {
665 | display: flex;
666 | flex-direction: row;
667 | gap: 1rem;
668 | }
669 |
670 | .Follow .preview .avatar {
671 | height: 50px;
672 | width: 50px;
673 | }
674 |
675 | .showThread {
676 | display: inline-block;
677 | margin: 0px auto;
678 | padding: 0.25rem 1rem;
679 | border-radius: 4px;
680 | background: var(--shade);
681 | text-decoration: none;
682 | }
683 |
684 | .moreLink {
685 | display: inline-block;
686 | margin: 1rem auto;
687 | font-size: 1.25rem;
688 | padding: 0.25rem 1rem;
689 | border-radius: 4px;
690 | background: var(--shade);
691 | text-decoration: none;
692 | }
693 |
694 | .box {
695 | margin: 1rem;
696 | background: var(--tertiary);
697 | }
698 |
699 | .box a {
700 | display: flex;
701 | padding: 1rem;
702 | text-decoration: none;
703 | }
704 |
705 | .box a span {
706 | flex-grow: 1;
707 | text-align: right;
708 | }
709 |
710 | .box form {
711 | padding: 1rem;
712 | }
713 |
714 | .box fieldset {
715 | margin-bottom: 1rem;
716 | }
717 |
718 | .box fieldset p {
719 | display: flex;
720 | }
721 |
722 | .box fieldset p label {
723 | width: 100px;
724 | flex-grow: 0;
725 | flex-shrink: 0;
726 | }
727 |
728 | .box fieldset legend {
729 | font-weight: bold;
730 | }
731 |
732 | #emojis input {
733 | font-size: 2rem;
734 | }
735 |
736 |
737 | .profileHeader {
738 | height: 150px;
739 | background: var(--separator);
740 | }
741 |
742 | .profileHeader img {
743 | object-fit: cover;
744 | height: 150px;
745 | width: 100%;
746 | }
747 |
748 | /* .profile {
749 | background: var(--tertiary);
750 | } */
751 |
752 |
753 | .profileToolbar {
754 | padding: 1rem;
755 | display: flex;
756 | }
757 |
758 | .profileToolbar .avatarLink {
759 | flex-grow: 0;
760 | margin-top: calc(-50px - 1rem);
761 | }
762 |
763 | .profileToolbar .avatar {
764 | width: 110px;
765 | height: 110px;
766 | border: 5px solid var(--tertiary);
767 | }
768 |
769 | .profileToolbar .tools {
770 | text-align: right;
771 | flex-grow: 1;
772 | }
773 |
774 |
775 | .profileBody {
776 | padding: 0 1rem 1rem;
777 | }
778 |
779 | .profileBody .author {
780 | font-size: 1.25rem;
781 | margin-bottom: 0.25rem;
782 | }
783 |
784 |
785 | .inbox {
786 | display: flex;
787 | flex-direction: column;
788 | position: relative;
789 | padding-bottom: 60px;
790 | height: calc(100vh - 90px);
791 | }
792 |
793 | @media screen and (min-width: 769px) {
794 | .inbox {
795 | padding-bottom: 0;
796 | height: calc(100vh - 65px);
797 | }
798 | }
799 |
800 | .inbox .messages {
801 | flex-grow: 1;
802 | flex-direction: column-reverse;
803 | display: flex;
804 | overflow-y: auto;
805 | }
806 |
807 | .inbox .message {
808 | margin: 0.25rem 1rem;
809 | }
810 |
811 | .message_text {
812 | background: var(--highlight);
813 | border-radius: 0.5rem;
814 | padding: 1rem;
815 | max-width: 75%;
816 | display: inline-block;
817 | }
818 |
819 | .message_timestamp {
820 | margin-top: 0.25rem;
821 | font-size: 0.8rem;
822 | color: var(--highlight);
823 | }
824 |
825 | .inbox .message_text *:first-child {
826 | margin-top: 0;
827 | }
828 |
829 | .inbox .message_text *:last-child {
830 | margin-bottom: 0;
831 | }
832 |
833 | .inbox .message.outgoing {
834 | display: flex;
835 | flex-direction: column;
836 | align-items: flex-end;
837 | }
838 |
839 | .inbox .message.outgoing .message_text {
840 | background: var(--shade);
841 | }
842 |
843 | .feeds {
844 | flex-shrink: 1;
845 | background: var(--tertiary);
846 | }
847 |
848 | .feeds.inbox_visible {
849 | display: none;
850 | }
851 |
852 | @media screen and (min-width: 769px) {
853 |
854 | #composer.mini {
855 | background: none;
856 | border-bottom: none;
857 | bottom: 0;
858 | left: 0;
859 | position: inherit;
860 | }
861 | }
862 |
863 | .feeds .feed {
864 | position: relative;
865 | padding: 0.5rem 1rem;
866 | padding-right: 4rem;
867 | border-bottom: 1px solid var(--secondary);
868 | display: flex;
869 | align-items: center;
870 | }
871 |
872 | .feeds .feed .avatar {
873 | width: 25px;
874 | height: 25px;
875 | margin-right: 1rem;
876 | }
877 |
878 | .feeds .feed a {
879 | text-decoration: none;
880 | }
881 |
882 | .feeds .unread {
883 | font-weight: bold;
884 | border-right: 10px solid var(--accent);
885 | /* background: var(--secondary); */
886 | }
887 |
888 | .feeds .current {
889 | /* font-weight: bold; */
890 | background: var(--secondary);
891 | }
892 |
893 | .meta_tag {
894 | background: var(--shade);
895 | color: var(--contrast);
896 | border-radius: 3px;
897 | font-size: 0.7rem;
898 | padding: 0.15rem;
899 | }
900 |
901 | details#bio {
902 | margin-top: 1rem;
903 | }
--------------------------------------------------------------------------------
/public/images/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benbrown/shuttlecraft/ef489a33c92ed1857509ed57d09fa55fd7811da3/public/images/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/images/avatar-unknown.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benbrown/shuttlecraft/ef489a33c92ed1857509ed57d09fa55fd7811da3/public/images/avatar-unknown.png
--------------------------------------------------------------------------------
/public/images/avatar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benbrown/shuttlecraft/ef489a33c92ed1857509ed57d09fa55fd7811da3/public/images/avatar.png
--------------------------------------------------------------------------------
/public/images/header.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benbrown/shuttlecraft/ef489a33c92ed1857509ed57d09fa55fd7811da3/public/images/header.png
--------------------------------------------------------------------------------
/routes/account.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { getFollowers } from '../lib/account.js';
3 | export const router = express.Router();
4 |
5 | router.get('/:name', function (req, res) {
6 | let name = req.params.name;
7 | if (!name) {
8 | return res.status(400).send('Bad request.');
9 | } else {
10 | const domain = req.app.get('domain');
11 | // const username = name;
12 | name = `https://${domain}/u/${name}`;
13 |
14 | if (name !== req.app.get('account').actor.id) {
15 | return res.status(404).send(`No record found for ${name}.`);
16 | } else {
17 | if (req.headers.accept?.includes('application/ld+json')) {
18 | res.json(req.app.get('account').actor);
19 | } else {
20 | res.redirect(req.app.get('account').actor.url || `https://${domain}/`);
21 | }
22 | }
23 | }
24 | });
25 |
26 | router.get('/:name/followers', function (req, res) {
27 | let name = req.params.name;
28 | if (!name) {
29 | return res.status(400).send('Bad request.');
30 | } else {
31 | const domain = req.app.get('domain');
32 |
33 | name = `https://${domain}/u/${name}`;
34 |
35 | if (name !== req.app.get('account').actor.id) {
36 | return res.status(404).send(`No record found for ${name}.`);
37 | } else {
38 | const followers = getFollowers();
39 | const followersCollection = {
40 | type: 'OrderedCollection',
41 | totalItems: followers.length,
42 | id: `https://${domain}/u/${name}/followers`,
43 | first: {
44 | type: 'OrderedCollectionPage',
45 | totalItems: followers.length,
46 | partOf: `https://${domain}/u/${name}/followers`,
47 | orderedItems: followers,
48 | id: `https://${domain}/u/${name}/followers?page=1`
49 | },
50 | '@context': ['https://www.w3.org/ns/activitystreams']
51 | };
52 | res.json(followersCollection);
53 | }
54 | }
55 | });
56 |
--------------------------------------------------------------------------------
/routes/admin.js:
--------------------------------------------------------------------------------
1 | import { getActivity } from '../lib/notes.js';
2 | import { getActivitySince, getActivityStream, getFullPostDetails, sortByDate } from '../lib/theAlgorithm.js';
3 | import express from 'express';
4 | import debug from 'debug';
5 | import {
6 | getFollowers,
7 | getFollowing,
8 | writeFollowing,
9 | createNote,
10 | getNotifications,
11 | getNote,
12 | getLikes,
13 | writeLikes,
14 | getBoosts,
15 | writeBoosts,
16 | isFollower,
17 | isFollowing,
18 | getInboxIndex,
19 | getInbox,
20 | writeInboxIndex
21 | } from '../lib/account.js';
22 | import { fetchUser } from '../lib/users.js';
23 | import { getPrefs, INDEX, searchKnownUsers, updatePrefs } from '../lib/storage.js';
24 | import { ActivityPub } from '../lib/ActivityPub.js';
25 | import { queue } from '../lib/queue.js';
26 | export const router = express.Router();
27 | const logger = debug('ono:admin');
28 |
29 | router.get('/index', async (req, res) => {
30 | res.json(INDEX);
31 | });
32 |
33 | /**
34 | _____ _____ _ _ ____ ____ _____ _____
35 | | __ \ /\ / ____| | | | _ \ / __ \ /\ | __ \| __ \
36 | | | | | / \ | (___ | |__| | |_) | | | | / \ | |__) | | | |
37 | | | | |/ /\ \ \___ \| __ | _ <| | | |/ /\ \ | _ /| | | |
38 | | |__| / ____ \ ____) | | | | |_) | |__| / ____ \| | \ \| |__| |
39 | |_____/_/ \_\_____/|_| |_|____/ \____/_/ \_\_| \_\_____/
40 |
41 | */
42 | router.get('/', async (req, res) => {
43 | const offset = parseInt(req.query.offset) || 0;
44 | const pageSize = 20;
45 |
46 | const { activitystream, next } = await getActivityStream(pageSize, offset);
47 |
48 | const feeds = await getFeedList();
49 |
50 | if (req.query.json) {
51 | res.json(activitystream);
52 | } else {
53 | // set auth cookie
54 | res.cookie('token', ActivityPub.account.apikey, { maxAge: 7 * 24 * 60 * 60 * 1000 });
55 |
56 | res.render('dashboard', {
57 | layout: 'private',
58 | url: '/',
59 | me: ActivityPub.actor,
60 | offset,
61 | next: activitystream.length === pageSize ? next : null,
62 | activitystream,
63 | feeds,
64 | prefs: getPrefs()
65 | });
66 | }
67 | });
68 |
69 | /**
70 | _ _ ____ _______ _____ ______ _____ _____ _______ _____ ____ _ _ _____
71 | | \ | |/ __ \__ __|_ _| ____|_ _/ ____| /\|__ __|_ _/ __ \| \ | |/ ____|
72 | | \| | | | | | | | | | |__ | || | / \ | | | || | | | \| | (___
73 | | . ` | | | | | | | | | __| | || | / /\ \ | | | || | | | . ` |\___ \
74 | | |\ | |__| | | | _| |_| | _| || |____ / ____ \| | _| || |__| | |\ |____) |
75 | |_| \_|\____/ |_| |_____|_| |_____\_____/_/ \_\_| |_____\____/|_| \_|_____/
76 |
77 | */
78 | router.get('/notifications', async (req, res) => {
79 | const likes = await getLikes();
80 | const offset = parseInt(req.query.offset) || 0;
81 | const pageSize = 20;
82 | const notes = getNotifications()
83 | .slice()
84 | .reverse()
85 | .slice(offset, offset + pageSize);
86 | const notifications = await Promise.all(
87 | notes.map(async notification => {
88 | const { actor } = await fetchUser(notification.notification.actor);
89 | let note, original;
90 | // TODO: check if user is in following list
91 | actor.isFollowing = isFollowing(actor.id);
92 |
93 | if (notification.notification.type === 'Like' || notification.notification.type === 'Announce') {
94 | note = await getNote(notification.notification.object);
95 | }
96 | if (notification.notification.type === 'Reply') {
97 | try {
98 | note = await getActivity(notification.notification.object);
99 | original = await getNote(note.inReplyTo);
100 | note.isLiked = !!likes.some(l => l.activityId === note.id);
101 | } catch (err) {
102 | console.error('Could not fetch parent post', err);
103 | return null;
104 | }
105 | }
106 | if (notification.notification.type === 'Mention') {
107 | try {
108 | note = await getActivity(notification.notification.object);
109 | note.isLiked = !!likes.some(l => l.activityId === note.id);
110 | } catch (err) {
111 | console.log('Could not fetch mention post', err);
112 | return null;
113 | }
114 | }
115 |
116 | return {
117 | actor,
118 | note,
119 | original,
120 | ...notification
121 | };
122 | })
123 | );
124 |
125 | const following = getFollowing();
126 | const followers = getFollowers();
127 |
128 | const feeds = await getFeedList();
129 |
130 | res.render('notifications', {
131 | layout: 'private',
132 | prefs: getPrefs(),
133 | me: ActivityPub.actor,
134 | url: '/notifications',
135 | offset,
136 | feeds,
137 | next: notifications.length === pageSize ? offset + notifications.length : null,
138 | notifications: notifications.filter(n => n !== null),
139 | followersCount: followers.length,
140 | followingCount: following.length
141 | });
142 | });
143 |
144 | router.get('/feeds/:handle?', async (req, res) => {
145 | const offset = parseInt(req.query.offset) || 0;
146 | const pageSize = 20;
147 | let feed;
148 |
149 | let feedcount = 20;
150 | if (req.query.expandfeeds) {
151 | feedcount = 120;
152 | }
153 | const feeds = await getFeedList(0, feedcount);
154 |
155 | let activitystream;
156 |
157 | if (req.params.handle) {
158 | const account = await fetchUser(req.params.handle);
159 | feed = account.actor;
160 | feed.isFollowing = isFollowing(feed.id);
161 | feed.isFollower = isFollower(feed.id);
162 |
163 | if (feed.id === req.app.get('account').actor.id || isFollowing(feed.id)) {
164 | logger('Loading posts from index for', feed.id);
165 | activitystream = await Promise.all(
166 | INDEX.filter(p => p.actor === account.actor.id)
167 | .sort(sortByDate)
168 | .slice(offset, offset + pageSize)
169 | .map(async p => {
170 | try {
171 | return getFullPostDetails(p.id);
172 | } catch (err) {
173 | console.error('error while loading post from index', err);
174 | }
175 | })
176 | .filter(p => p !== undefined) // remove items where we couldn't load the boost
177 | );
178 | } else {
179 | logger('Loading remote posts for', feed.id);
180 | const { items } = await ActivityPub.fetchOutbox(feed);
181 |
182 | activitystream = !items
183 | ? []
184 | : await Promise.all(
185 | items
186 | .filter(post => {
187 | // filter to only include posts and boosts
188 | return post.type === 'Create' || post.type === 'Announce';
189 | })
190 | .map(async post => {
191 | try {
192 | if (post.type === 'Create') {
193 | return getFullPostDetails(post.object);
194 | } else {
195 | return getFullPostDetails(post);
196 | }
197 | } catch (err) {
198 | console.error('error while loading post from remote outbox', err);
199 | }
200 | })
201 | );
202 | }
203 | }
204 |
205 | // res.json(activitystream);
206 | // return;
207 | res.render('feeds', {
208 | layout: 'private',
209 | me: ActivityPub.actor,
210 | url: '/feeds',
211 | prefs: getPrefs(),
212 | feeds,
213 | feed,
214 | expandfeeds: req.query.expandfeeds,
215 | activitystream,
216 | offset,
217 | next: activitystream && activitystream.length === pageSize ? offset + activitystream.length : null
218 | });
219 | });
220 |
221 | router.get('/dms/:handle?', async (req, res) => {
222 | const inboxIndex = getInboxIndex();
223 | let error, inbox, recipient, lastIncoming;
224 |
225 | if (req.params.handle) {
226 | // first validate that this is a real user
227 | try {
228 | const account = await fetchUser(req.params.handle);
229 | recipient = account.actor;
230 | inbox = getInbox(recipient.id);
231 |
232 | // reverse sort!
233 | inbox && inbox.sort(sortByDate);
234 |
235 | // find last message in thread
236 | lastIncoming = inbox.length ? inbox[0] : null;
237 |
238 | // mark all of these messages as seen
239 | if (inboxIndex[recipient.id]) {
240 | inboxIndex[recipient.id].lastRead = new Date().getTime();
241 | writeInboxIndex(inboxIndex);
242 | }
243 | } catch (err) {
244 | error = {
245 | message: `Could not load user: ${err.message}`
246 | };
247 | }
248 | }
249 |
250 | const inboxes = await Promise.all(
251 | Object.keys(inboxIndex).map(async k => {
252 | const acct = await fetchUser(k);
253 | return {
254 | id: k,
255 | actorId: k,
256 | actor: acct.actor,
257 | unread: !inboxIndex[k].lastRead || inboxIndex[k].lastRead < inboxIndex[k].latest,
258 | ...inboxIndex[k]
259 | };
260 | })
261 | );
262 |
263 | inboxes.sort((a, b) => {
264 | if (a.latest > b.latest) {
265 | return -1;
266 | } else if (a.latest < b.latest) {
267 | return 1;
268 | } else {
269 | return 0;
270 | }
271 | });
272 |
273 | res.render('dms', {
274 | layout: 'private',
275 | nonav: true,
276 | me: ActivityPub.actor,
277 | prefs: getPrefs(),
278 | url: '/dms',
279 | lastIncoming: lastIncoming ? lastIncoming.id : null,
280 | feeds: inboxes,
281 | inbox,
282 | feed: recipient,
283 | error
284 | });
285 | });
286 |
287 | router.get('/post', async (req, res) => {
288 | const to = req.query.to;
289 | const inReplyTo = req.query.inReplyTo;
290 | let op;
291 | let actor;
292 | let prev;
293 | if (inReplyTo) {
294 | op = await getActivity(inReplyTo);
295 | const account = await fetchUser(op.attributedTo);
296 | actor = account.actor;
297 | }
298 |
299 | if (req.query.edit) {
300 | console.log('COMPOSING EDIT', req.query.edit);
301 | prev = await getNote(req.query.edit);
302 | // console.log("ORIGINAL", original);
303 | }
304 |
305 | res.status(200).render('partials/composer', {
306 | url: '/post',
307 | to,
308 | inReplyTo,
309 | actor,
310 | originalPost: op, // original post being replied to
311 | prev, // previous version we posted, now editing
312 | me: req.app.get('account').actor,
313 | prefs: getPrefs(),
314 | layout: 'private'
315 | });
316 | });
317 |
318 | router.post('/post', async (req, res) => {
319 | // TODO: this is probably supposed to be a post to /api/outbox
320 | const post = await createNote(req.body.post, req.body.cw, req.body.inReplyTo, req.body.to, req.body.editOf);
321 | if (post.directMessage === true) {
322 | // return html partial of the new post for insertion in the feed
323 | res.status(200).render('partials/dm', {
324 | message: post,
325 | actor: req.app.get('account').actor,
326 | me: req.app.get('account').actor,
327 | layout: null
328 | });
329 | } else {
330 | // return html partial of the new post for insertion in the feed
331 | res.status(200).render('partials/note', {
332 | note: post,
333 | actor: req.app.get('account').actor,
334 | layout: 'activity'
335 | });
336 | }
337 | });
338 |
339 | router.get('/poll', async (req, res) => {
340 | const sincePosts = new Date(req.cookies.latestPost).getTime();
341 | const sinceNotifications = parseInt(req.cookies.latestNotification);
342 | const notifications = getNotifications().filter(n => n.time > sinceNotifications);
343 | const inboxIndex = getInboxIndex();
344 | const unreadDM =
345 | Object.keys(inboxIndex).filter(k => {
346 | return !inboxIndex[k].lastRead || inboxIndex[k].lastRead < inboxIndex[k].latest;
347 | })?.length || 0;
348 |
349 | const { activitystream } = await getActivitySince(sincePosts, true);
350 | res.json({
351 | newPosts: activitystream.length,
352 | newNotifications: notifications.length,
353 | newDMs: unreadDM
354 | });
355 | });
356 |
357 | router.get('/followers', async (req, res) => {
358 | let following = await Promise.all(
359 | getFollowing().map(async f => {
360 | const acct = await fetchUser(f.actorId);
361 | if (acct?.actor?.id) {
362 | acct.actor.isFollowing = true; // duh
363 | return acct.actor;
364 | }
365 | return undefined;
366 | })
367 | );
368 |
369 | following = following.filter(f => f !== undefined);
370 |
371 | let followers = await Promise.all(
372 | getFollowers().map(async f => {
373 | const acct = await fetchUser(f);
374 | if (acct?.actor?.id) {
375 | acct.actor.isFollowing = following.some(p => p.id === f);
376 | return acct.actor;
377 | }
378 | return undefined;
379 | })
380 | );
381 |
382 | followers = followers.filter(f => f !== undefined);
383 |
384 | if (req.query.json) {
385 | const notes = {}; // FIXME: Where are the notes coming from?
386 | res.json(notes);
387 | } else {
388 | res.render('followers', {
389 | layout: 'private',
390 | url: '/followers',
391 | prefs: getPrefs(),
392 | me: ActivityPub.actor,
393 | followers,
394 | following,
395 | followersCount: followers.length,
396 | followingCount: following.length
397 | });
398 | }
399 | });
400 |
401 | router.get('/following', async (req, res) => {
402 | let following = await Promise.all(
403 | getFollowing().map(async f => {
404 | const acct = await fetchUser(f.actorId);
405 | if (acct?.actor?.id) {
406 | acct.actor.isFollowing = true; // duh
407 | return acct.actor;
408 | }
409 | return undefined;
410 | })
411 | );
412 | following = following.filter(f => f !== undefined);
413 |
414 | let followers = await Promise.all(
415 | getFollowers().map(async f => {
416 | const acct = await fetchUser(f);
417 | if (acct?.actor?.id) {
418 | acct.actor.isFollowing = following.some(p => p.id === f);
419 | return acct.actor;
420 | }
421 | return undefined;
422 | })
423 | );
424 |
425 | followers = followers.filter(f => f !== undefined);
426 |
427 | if (req.query.json) {
428 | const notes = {}; // FIXME: Where are the notes coming from?
429 | res.json(notes);
430 | } else {
431 | res.render('following', {
432 | layout: 'private',
433 | url: '/followers',
434 | prefs: getPrefs(),
435 | me: ActivityPub.actor,
436 | followers,
437 | following,
438 | followersCount: followers.length,
439 | followingCount: following.length
440 | });
441 | }
442 | });
443 |
444 | /**
445 | _____ _____ ______ ______ _____
446 | | __ \| __ \| ____| ____/ ____|
447 | | |__) | |__) | |__ | |__ | (___
448 | | ___/| _ /| __| | __| \___ \
449 | | | | | \ \| |____| | ____) |
450 | |_| |_| \_\______|_| |_____/
451 |
452 | */
453 | router.get('/prefs', (req, res) => {
454 | const following = getFollowing();
455 | const followers = getFollowers();
456 |
457 | res.render('prefs', {
458 | layout: 'private',
459 | url: '/prefs',
460 | queue: {
461 | size: queue.size,
462 | state: queue.state,
463 | shouldRun: queue.shouldRun
464 | },
465 | prefs: getPrefs(),
466 | me: ActivityPub.actor,
467 | followersCount: followers.length,
468 | followingCount: following.length
469 | });
470 | });
471 |
472 | router.post('/prefs', (req, res) => {
473 | // lget current prefs.
474 | const prefs = getPrefs();
475 |
476 | // incoming prefs
477 | const updates = req.body;
478 |
479 | console.log('GOT UPDATES', updates);
480 | res.redirect('/private/prefs');
481 | Object.keys(updates).forEach(key => {
482 | // split the fieldname into parts
483 | const [type, keyname] = key.split(/\./);
484 |
485 | // update the pref in place
486 | prefs[type][keyname] = updates[key];
487 | });
488 |
489 | updatePrefs(prefs);
490 | });
491 |
492 | const getFeedList = async (offset = 0, num = 20) => {
493 | const following = await getFollowing();
494 |
495 | const feeds = await Promise.all(
496 | following.map(async follower => {
497 | // posts in index by this author
498 | // this is probably expensive.
499 | // what we really need to do is look from this person by date
500 | // and if we sort right it should be reasonable?
501 | // and we just return unread counts for everything?
502 | const posts = INDEX.filter(p => p.actor === follower.actorId);
503 |
504 | // find most recent post
505 | const mostRecent = posts.sort(sortByDate)[0]?.published || null;
506 |
507 | const account = await fetchUser(follower.actorId);
508 |
509 | return {
510 | actorId: follower.actorId,
511 | actor: account.actor,
512 | postCount: posts.length,
513 | mostRecent
514 | };
515 | })
516 | );
517 |
518 | feeds.sort((a, b) => {
519 | if (a.mostRecent > b.mostRecent) {
520 | return -1;
521 | } else if (a.mostRecent < b.mostRecent) {
522 | return 1;
523 | } else {
524 | return 0;
525 | }
526 | });
527 |
528 | return feeds.slice(offset, offset + num);
529 | };
530 |
531 | router.get('/find', async (req, res) => {
532 | let results = [];
533 |
534 | // can we find an exact match
535 | try {
536 | const { actor } = await fetchUser(req.query.handle);
537 | if (actor && actor.id) {
538 | actor.isFollowing = isFollowing(actor.id);
539 | results.push(actor);
540 | }
541 | } catch (err) {
542 | // not found
543 | }
544 |
545 | if (results.length === 0) {
546 | const search = await searchKnownUsers(req.query.handle.toLowerCase());
547 | if (search.length) {
548 | results = results.concat(search);
549 | }
550 | }
551 |
552 | res.status(200).render('findresults', {
553 | layout: 'private',
554 | url: '/find',
555 | query: req.query.handle,
556 | me: ActivityPub.actor,
557 | prefs: getPrefs(),
558 | results
559 | });
560 | });
561 |
562 | router.get('/morefeeds', async (req, res) => {
563 | const feeds = await getFeedList(20, 100);
564 |
565 | res.render('partials/feeds', {
566 | layout: null,
567 | feeds,
568 | expandfeeds: true
569 | });
570 | });
571 |
572 | router.get('/lookup', async (req, res) => {
573 | const { actor } = await fetchUser(req.query.handle);
574 | if (actor) {
575 | actor.isFollowing = isFollowing(actor.id);
576 | res.status(200).render('partials/personCard', {
577 | actor,
578 | layout: null
579 | });
580 | } else {
581 | res.status(200).send('No user found');
582 | }
583 | });
584 |
585 | router.post('/follow', async (req, res) => {
586 | const handle = req.body.handle;
587 | if (handle) {
588 | logger('toggle follow', handle);
589 | if (handle === req.app.get('account').actor.id) {
590 | return res.status(200).json({
591 | isFollowed: false
592 | });
593 | }
594 | const { actor } = await fetchUser(handle);
595 | if (actor) {
596 | const status = isFollowing(actor.id);
597 | if (!status) {
598 | ActivityPub.sendFollow(actor);
599 |
600 | return res.status(200).json({
601 | isFollowed: true
602 | });
603 | } else {
604 | // send unfollow
605 | await ActivityPub.sendUndoFollow(actor, status.id);
606 |
607 | // todo: this should just be a function like removeFollowing
608 |
609 | let following = getFollowing();
610 |
611 | // filter out the one we are removing
612 | following = following.filter(l => l.actorId !== actor.id);
613 |
614 | writeFollowing(following);
615 |
616 | return res.status(200).json({
617 | isFollowed: false
618 | });
619 | }
620 | }
621 | }
622 | res.status(404).send('not found');
623 | });
624 |
625 | router.post('/like', async (req, res) => {
626 | const activityId = req.body.post;
627 | let likes = getLikes();
628 | if (!likes.some(l => l.activityId === activityId)) {
629 | const post = await getActivity(activityId);
630 | const recipient = await fetchUser(post.attributedTo);
631 | const message = await ActivityPub.sendLike(post, recipient.actor);
632 | const guid = message.id;
633 |
634 | likes.push({
635 | id: guid,
636 | activityId
637 | });
638 | res.status(200).json({
639 | isLiked: true
640 | });
641 | } else {
642 | // extract so we can send an undo record
643 | const recordToUndo = likes.find(l => l.activityId === activityId);
644 |
645 | const post = await getActivity(activityId);
646 | const recipient = await fetchUser(post.attributedTo);
647 |
648 | await ActivityPub.sendUndoLike(post, recipient.actor, recordToUndo.id);
649 |
650 | // filter out the one we are removing
651 | likes = likes.filter(l => l.activityId !== activityId);
652 |
653 | // send status back
654 | res.status(200).json({
655 | isLiked: false
656 | });
657 | }
658 | writeLikes(likes);
659 | });
660 |
661 | router.post('/boost', async (req, res) => {
662 | const activityId = req.body.post;
663 | let boosts = getBoosts();
664 | if (!boosts.some(l => l.activityId === activityId)) {
665 | const post = await getActivity(activityId);
666 | const account = await fetchUser(post.attributedTo);
667 | const followers = await getFollowers();
668 | const fullFollowers = await Promise.all(
669 | followers.map(async follower => {
670 | const { actor } = await fetchUser(follower);
671 | return actor;
672 | })
673 | );
674 | const message = await ActivityPub.sendBoost(account.actor, post, fullFollowers);
675 |
676 | boosts.push({
677 | id: message.id,
678 | activityId
679 | });
680 | res.status(200).json({
681 | isBoosted: true
682 | });
683 | } else {
684 | // extract so we can send an undo record
685 | const recordToUndo = boosts.find(l => l.activityId === activityId);
686 | const post = await getActivity(activityId);
687 | const account = await fetchUser(post.attributedTo);
688 | const followers = await getFollowers();
689 | const fullFollowers = await Promise.all(
690 | followers.map(async follower => {
691 | const { actor } = await fetchUser(follower);
692 | return actor;
693 | })
694 | );
695 | await ActivityPub.sendUndoBoost(account.actor, post, fullFollowers, recordToUndo.id);
696 |
697 | // filter out the one we are removing
698 | boosts = boosts.filter(l => l.activityId !== activityId);
699 |
700 | // send status back
701 | res.status(200).json({
702 | isBoosted: false
703 | });
704 | }
705 | writeBoosts(boosts);
706 | });
707 |
--------------------------------------------------------------------------------
/routes/inbox.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { ActivityPub } from '../lib/ActivityPub.js';
3 | import { fetchUser } from '../lib/users.js';
4 | import {
5 | acceptDM,
6 | addFollower,
7 | removeFollower,
8 | follow,
9 | isReplyToMyPost,
10 | addNotification,
11 | isMyPost,
12 | isBlocked,
13 | addressedOnlyToMe,
14 | isMention,
15 | deleteObject
16 | } from '../lib/account.js';
17 | import { createActivity, recordLike, recordUndoLike, recordBoost, getActivity } from '../lib/notes.js';
18 | import debug from 'debug';
19 | import { isIndexed } from '../lib/storage.js';
20 | export const router = express.Router();
21 | const logger = debug('ono:inbox');
22 |
23 | router.post('/', async (req, res) => {
24 | const incomingRequest = req.body;
25 |
26 | if (incomingRequest) {
27 | if (isBlocked(incomingRequest.actor)) {
28 | return res.status(403).send('');
29 | }
30 |
31 | logger('New message', JSON.stringify(incomingRequest, null, 2));
32 | logger('Looking up actor', incomingRequest.actor);
33 | const { actor } = await fetchUser(incomingRequest.actor);
34 |
35 | // FIRST, validate the actor
36 | if (ActivityPub.validateSignature(actor, req)) {
37 | switch (incomingRequest.type) {
38 | case 'Delete':
39 | logger('Delete request');
40 | await deleteObject(actor, incomingRequest);
41 | break;
42 | case 'Follow':
43 | logger('Incoming follow request');
44 | addFollower(incomingRequest);
45 |
46 | // TODO: should wait to confirm follow acceptance?
47 | ActivityPub.sendAccept(actor, incomingRequest);
48 | break;
49 | case 'Undo':
50 | logger('Incoming undo');
51 | switch (incomingRequest.object.type) {
52 | case 'Follow':
53 | logger('Incoming unfollow request');
54 | removeFollower(incomingRequest.actor);
55 | break;
56 | case 'Like':
57 | logger('Incoming undo like request');
58 | recordUndoLike(incomingRequest.object);
59 | break;
60 | default:
61 | logger('Unknown undo type');
62 | }
63 | break;
64 | case 'Accept':
65 | switch (incomingRequest.object.type) {
66 | case 'Follow':
67 | logger('Incoming follow request');
68 | follow(incomingRequest);
69 | break;
70 | default:
71 | logger('Unknown undo type');
72 | }
73 | break;
74 | case 'Like':
75 | logger('Incoming like');
76 | recordLike(incomingRequest);
77 | break;
78 | case 'Announce':
79 | logger('Incoming boost');
80 | // determine if this is a boost on MY post
81 | // or someone boosting a post into my feed. DIFFERENT!
82 | if (
83 | isMyPost({
84 | id: incomingRequest.object
85 | })
86 | ) {
87 | recordBoost(incomingRequest);
88 | } else {
89 | // fetch the boosted post if it doesn't exist
90 | try {
91 | await getActivity(incomingRequest.object);
92 | } catch (err) {
93 | console.error('Could not fetch boosted post');
94 | }
95 |
96 | // log the boost itself to the activity stream
97 | try {
98 | await createActivity(incomingRequest);
99 | } catch (err) {
100 | console.error('Could not fetch boosted post...');
101 | }
102 | }
103 | break;
104 | case 'Create':
105 | logger('incoming create');
106 |
107 | // determine what type of post this is, if it should show up, etc.
108 | // - a post that is a reply to your own post from someone you follow (notification AND feed)
109 | // - a post that is a reply to your own post from someone you do not follow (notification only)
110 | // - a post that that is from someone you follow, and is a reply to a post from someone you follow (in feed)
111 | // - a post that is from someone you follow, but is a reply to a post from someone you do not follow (should be ignored?)
112 | // - a mention from a following (notification and feed)
113 | // - a mention from a stranger (notification only)
114 | if (incomingRequest.object.directMessage === true || addressedOnlyToMe(incomingRequest)) {
115 | await acceptDM(incomingRequest.object, incomingRequest.object.attributedTo);
116 | } else if (isReplyToMyPost(incomingRequest.object)) {
117 | // TODO: What about replies to replies? should we traverse up a bit?
118 | if (!isIndexed(incomingRequest.object.id)) {
119 | await createActivity(incomingRequest.object);
120 | addNotification({
121 | type: 'Reply',
122 | actor: incomingRequest.object.attributedTo,
123 | object: incomingRequest.object.id
124 | });
125 | } else {
126 | logger('already created reply');
127 | }
128 | } else if (isMention(incomingRequest.object)) {
129 | if (!isIndexed(incomingRequest.object.id)) {
130 | await createActivity(incomingRequest.object);
131 | addNotification({
132 | type: 'Mention',
133 | actor: incomingRequest.object.attributedTo,
134 | object: incomingRequest.object.id
135 | });
136 | } else {
137 | logger('already created mention');
138 | }
139 | } else if (!incomingRequest.object.inReplyTo) {
140 | // this is a NEW post - most likely from a follower
141 | await createActivity(incomingRequest.object);
142 | } else {
143 | // this is a reply
144 | // from a following
145 | // or from someone else who replied to a following?
146 | // the visibility should be determined on the feed
147 | // TODO: we may want to discard things NOT from followings
148 | // since they may never be seen
149 | // and we can always go fetch them...
150 | await createActivity(incomingRequest.object);
151 | }
152 |
153 | break;
154 | case 'Update':
155 | await createActivity(incomingRequest.object);
156 | break;
157 | default:
158 | logger('Unknown request type:', incomingRequest.type);
159 | }
160 | } else {
161 | logger('Signature failed:', incomingRequest);
162 | return res.status(403).send('Invalid signature');
163 | }
164 | } else {
165 | logger('Unknown request format:', incomingRequest);
166 | }
167 | return res.status(200).send();
168 | });
169 |
--------------------------------------------------------------------------------
/routes/index.js:
--------------------------------------------------------------------------------
1 | export { router as account } from './account.js';
2 | export { router as webfinger } from './webfinger.js';
3 | export { router as inbox } from './inbox.js';
4 | export { router as outbox } from './outbox.js';
5 | export { router as admin } from './admin.js';
6 | export { router as notes } from './notes.js';
7 | export { router as publicFacing } from './public.js';
8 |
--------------------------------------------------------------------------------
/routes/notes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { getNote } from '../lib/account.js';
3 | import dotenv from 'dotenv';
4 | export const router = express.Router();
5 | dotenv.config();
6 |
7 | const { DOMAIN } = process.env;
8 |
9 | router.get('/:guid', async (req, res) => {
10 | const guid = req.params.guid;
11 | if (!guid) {
12 | return res.status(400).send('Bad request.');
13 | } else {
14 | const note = await getNote(`https://${DOMAIN}/m/${guid}`);
15 | if (note === undefined) {
16 | return res.status(404).send(`No record found for ${guid}.`);
17 | } else {
18 | if (req.headers.accept?.includes('application/ld+json; profile="https://www.w3.org/ns/activitystreams"')) {
19 | res.json(note);
20 | } else {
21 | res.redirect(note.url);
22 | }
23 | }
24 | }
25 | });
26 |
--------------------------------------------------------------------------------
/routes/outbox.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import dotenv from 'dotenv';
3 |
4 | import { getOutboxPosts } from '../lib/account.js';
5 | export const router = express.Router();
6 | dotenv.config();
7 |
8 | // const {
9 | // DOMAIN
10 | // } = process.env;
11 |
12 | router.get('/', async (req, res) => {
13 | const { total, posts } = await getOutboxPosts(req.query.offset || 0);
14 | const outboxUrl = req.app.get('account').actor.outbox;
15 |
16 | const collection = {
17 | type: 'OrderedCollection',
18 | totalItems: total,
19 | id: outboxUrl,
20 | '@context': ['https://www.w3.org/ns/activitystreams']
21 | };
22 |
23 | if (isNaN(req.query.offset)) {
24 | collection.first = `${outboxUrl}?offset=0`;
25 | } else {
26 | const offset = parseInt(req.query.offset);
27 | collection.type = 'OrderedCollectionPage';
28 | collection.id = `${outboxUrl}?offset=${offset}`;
29 | collection.partOf = outboxUrl;
30 | collection.next = `${outboxUrl}?offset=${offset + 10}`;
31 | // todo: stop at 0
32 | if (offset - 10 > 0) {
33 | collection.prev = `${outboxUrl}?offset=${offset - 10}`;
34 | } else {
35 | collection.first = `${outboxUrl}?offset=0`;
36 | }
37 | collection.orderedItems = posts;
38 | collection.orderedItems = collection.orderedItems.map(activity => {
39 | return {
40 | id: `${activity.id}/activity`,
41 | type: 'Create',
42 | actor: activity.attributedTo,
43 | published: activity.published,
44 | to: activity.to,
45 | cc: activity.cc,
46 | object: activity
47 | };
48 | });
49 | }
50 |
51 | res.json(collection);
52 | });
53 |
--------------------------------------------------------------------------------
/routes/public.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import debug from 'debug';
3 | import RSS from 'rss-generator';
4 | import dotenv from 'dotenv';
5 |
6 | import {
7 | getNote,
8 | isMyPost,
9 | // getAccount,
10 | getOutboxPosts
11 | } from '../lib/account.js';
12 | import { getActivity, getLikesForNote, getReplyCountForNote } from '../lib/notes.js';
13 | import { INDEX } from '../lib/storage.js';
14 | import { ActivityPub } from '../lib/ActivityPub.js';
15 |
16 | import { fetchUser } from '../lib/users.js';
17 | export const router = express.Router();
18 | dotenv.config();
19 |
20 | const { USERNAME, DOMAIN } = process.env;
21 |
22 | const logger = debug('notes');
23 |
24 | const unrollThread = async (noteId, results = [], ascend = true, descend = true) => {
25 | let post, actor;
26 | let stats;
27 | if (
28 | isMyPost({
29 | id: noteId
30 | })
31 | ) {
32 | try {
33 | post = await getNote(noteId);
34 | actor = ActivityPub.actor;
35 | const likes = getLikesForNote(post.id);
36 | stats = {
37 | likes: likes.likes.length,
38 | boosts: likes.boosts.length,
39 | replies: getReplyCountForNote(post.id)
40 | };
41 | } catch (err) {
42 | logger('could not fetch own post in thread', err);
43 | }
44 | } else {
45 | try {
46 | post = await getActivity(noteId);
47 | const account = await fetchUser(post.attributedTo);
48 | actor = account.actor;
49 | } catch (err) {
50 | logger('Could not load a post in a thread. Possibly deleted.', err);
51 | }
52 | }
53 |
54 | // can only check up stream if you can look at the post itself.
55 | // if it has been deleted, that info is lost.
56 | if (post) {
57 | results.push({
58 | stats,
59 | note: post,
60 | actor
61 | });
62 |
63 | // if this is a reply, get the parent and any other parents straight up the chain
64 | // this does NOT get replies to those parents that are not part of the active thread right now.
65 | if (ascend && post.inReplyTo) {
66 | try {
67 | await unrollThread(post.inReplyTo, results, true, false);
68 | } catch (err) {
69 | logger('Failed to unroll thread parents.', err);
70 | }
71 | }
72 | }
73 |
74 | // now, find all posts that are below this one...
75 | if (descend) {
76 | const replies = INDEX.filter(p => p.inReplyTo === noteId);
77 | for (let r = 0; r < replies.length; r++) {
78 | try {
79 | await unrollThread(replies[r].id, results, false, true);
80 | } catch (err) {
81 | logger('Failed to unroll thread children', err);
82 | }
83 | }
84 | }
85 |
86 | return results;
87 | };
88 |
89 | router.get('/', async (req, res) => {
90 | const offset = parseInt(req.query.offset) || 0;
91 | const {
92 | // total,
93 | posts
94 | } = await getOutboxPosts(offset);
95 | const actor = ActivityPub.actor;
96 | // const enrichedPosts = posts.map((post) => {
97 | // let stats;
98 | // if (isMyPost(post)) {
99 | // const likes = getLikesForNote(post.id)
100 | // stats = {
101 | // likes: likes.likes.length,
102 | // boosts: likes.boosts.length,
103 | // replies: getReplyCountForNote(post.id),
104 | // }
105 | // post.stats = stats;
106 | // }
107 | // return post;
108 | // })
109 |
110 | res.render('public/home', {
111 | me: ActivityPub.actor,
112 | actor,
113 | activitystream: posts,
114 | layout: 'public',
115 | next: offset + posts.length,
116 | domain: DOMAIN,
117 | user: USERNAME
118 | });
119 | });
120 |
121 | router.get('/feed', async (req, res) => {
122 | const {
123 | // total,
124 | posts
125 | } = await getOutboxPosts(0);
126 |
127 | const feed = new RSS({
128 | title: `${USERNAME}@${DOMAIN}`,
129 | site_url: DOMAIN,
130 | pubDate: posts[0].published
131 | });
132 |
133 | posts.forEach(post => {
134 | /* loop over data and add to feed */
135 | feed.item({
136 | title: post.subject,
137 | description: post.content,
138 | url: post.url,
139 | date: post.published // any format that js Date can parse.
140 | });
141 | });
142 |
143 | res.set('Content-Type', 'text/xml');
144 | res.send(
145 | feed.xml({
146 | indent: true
147 | })
148 | );
149 | });
150 |
151 | router.get('/notes/:guid', async (req, res) => {
152 | const guid = req.params.guid;
153 |
154 | if (!guid) {
155 | return res.status(400).send('Bad request.');
156 | } else {
157 | const actor = ActivityPub.actor;
158 | const note = await getNote(`https://${DOMAIN}/m/${guid}`);
159 | if (note === undefined) {
160 | return res.status(404).send(`No record found for ${guid}.`);
161 | } else {
162 | const notes = await unrollThread(note.id);
163 | notes.sort((a, b) => {
164 | const ad = new Date(a.note.published).getTime();
165 | const bd = new Date(b.note.published).getTime();
166 | if (ad > bd) {
167 | return 1;
168 | } else if (ad < bd) {
169 | return -1;
170 | } else {
171 | return 0;
172 | }
173 | });
174 | res.render('public/note', {
175 | me: ActivityPub.actor,
176 | actor,
177 | activitystream: notes,
178 | layout: 'public',
179 | domain: DOMAIN,
180 | user: USERNAME
181 | });
182 | }
183 | }
184 | });
185 |
--------------------------------------------------------------------------------
/routes/webfinger.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | export const router = express.Router();
3 |
4 | router.get('/', function (req, res) {
5 | const resource = req.query.resource;
6 | if (!resource || !resource.includes('acct:')) {
7 | return res
8 | .status(400)
9 | .send(
10 | 'Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.'
11 | );
12 | } else {
13 | if (resource === req.app.get('account').webfinger.subject) {
14 | res.json(req.app.get('account').webfinger);
15 | } else {
16 | return res.status(404).send(`No record found for ${resource}.`);
17 | }
18 | }
19 | });
20 |
--------------------------------------------------------------------------------