├── .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 |
13 | 🚀 boosted by {{booster.name}} 14 |
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 |
Inbox » Messages with {{feed.preferredUsername}}
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 |
16 | 🚀 boosted by {{booster.name}} 17 |
18 | {{> note note=note}} 19 | {{else}} 20 | {{> note note=note hidebyline=true}} 21 | {{/if}} 22 |
23 | {{/with}} 24 | {{/each}} 25 | {{else}} 26 | 27 |
28 | No posts. Reload 29 |
30 | {{/if}} 31 | 32 | {{#if next}} 33 | More 34 | {{/if}} 35 | 36 |
37 | {{/if}} 38 | -------------------------------------------------------------------------------- /design/findresults.handlebars: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 | 8 |
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 |
3 | 🤷🏽‍♂️ Followers 4 |
5 | 6 | {{#each followers}} 7 |
8 | {{> personCard actor=this}} 9 |
10 | {{/each}} 11 |
12 | {{> peopleTools}} 13 | -------------------------------------------------------------------------------- /design/following.handlebars: -------------------------------------------------------------------------------- 1 |
2 |
3 | 🤷🏽‍♂️ Following 4 |
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 |
💬 {{or ../actor.name ../actor.preferredUsername}} mentioned you {{timesince ../time}}
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 |
🤷🏽‍♂️ {{or ../actor.name ../actor.preferredUsername}} followed you {{timesince ../time}}
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 |
4 | {{actor.name}} 5 | {{getUsername actor.id}} 6 |
7 |
8 | -------------------------------------------------------------------------------- /design/partials/composer.handlebars: -------------------------------------------------------------------------------- 1 |
2 | {{#if originalPost}} 3 |
Reply
4 |
5 | {{> byline actor=actor}} 6 | {{{originalPost.content}}} 7 |
8 | {{else}} 9 | {{#if prev}} 10 |
Edit
11 | {{else}} 12 | {{/if}} 13 | {{/if}} 14 | 15 |
16 |
17 | 18 | 21 | 28 | 35 | {{#if prev}} 36 | 37 | {{/if}} 38 | 39 |
40 |
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 | 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 |
    8 | Toggle 9 |
    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 | {{../name}} 23 | {{/isImage}} 24 | {{#isVideo mediaType}} 25 | 26 | {{/isVideo}} 27 |
    28 | {{/each}} 29 | 48 |
    49 | -------------------------------------------------------------------------------- /design/partials/peopleTools.handlebars: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | My public page 4 | 5 | Followers: {{followersCount}} 6 | 7 | 8 | Following: {{followingCount}} 9 | 10 |
    11 |
    -------------------------------------------------------------------------------- /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 |
    2 | {{#if actor.image}} 3 | 4 | {{/if}} 5 |
    6 |
    7 | 10 |
    11 | 15 | {{@root.prefs.icons.messages}} 16 |
    17 |
    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 |
    Outbound Queue
    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 |
    17 |
    Preferences
    18 |
    19 |
    20 | 21 | Interface Strings 22 | 23 | 24 |

    25 | 26 | 27 |

    28 |
    29 |
    30 | 31 | Emoji Buttons 32 | 33 | 34 |

    35 | 36 | 37 |

    38 |

    39 | 40 | 41 |

    42 |

    43 | 44 | 45 |

    46 |

    47 | 48 | 49 |

    50 |

    51 | 52 | 53 |

    54 |

    55 | 56 | 57 |

    58 |

    59 | 60 | 61 |

    62 |

    63 | 64 | 65 |

    66 |

    67 | 68 | 69 |

    70 |

    71 | 72 | 73 |

    74 |

    75 | 76 | 77 |

    78 |

    79 | 80 | 81 |

    82 | 83 | 84 |
    85 |
    86 |
    -------------------------------------------------------------------------------- /design/public/home.handlebars: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |

    4 | Follow this site on Mastodon or other fediverse clients: 5 | 6 |

    7 |
    8 |
    9 | {{#if actor.image}} 10 | 11 | {{/if}} 12 |
    13 |
    14 | 17 |
    18 |
    19 |
    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 |
    42 | 🏠 Latest 43 | 44 |
    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 | {{../name}} 62 | {{/isImage}} 63 | {{#isVideo mediaType}} 64 | 65 | {{/isVideo}} 66 |
    67 | {{/each}} 68 | 69 |
    70 |
    71 | {{#if stats}} 72 |
    73 | ↩️ {{ stats.replies }} 74 |
    75 |
    76 | 🔁 {{ stats.boosts }} 77 |
    78 |
    79 | ⭐️ {{ stats.likes }} 80 |
    81 | {{/if}} 82 |
    83 | 84 |
    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 |
    9 | 10 | Back to {{actor.preferredUsername}} 11 |
    12 | {{#each activitystream}} 13 |
    14 | {{#with this}} 15 |
    16 | {{> avatar actor=actor public=true}} 17 |
    18 |
    {{actor.preferredUsername}}
    19 | {{getUsername actor.id}} 20 |
    21 |
    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 | {{../name}} 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 | --------------------------------------------------------------------------------