├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── containerize.yaml │ ├── linting.yml │ └── tests.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── build-scripts ├── .eslintrc.json ├── build-client.js ├── do-client-build.js ├── vite.config.js └── write-version-files.js ├── client ├── css │ ├── room-directory.css │ └── styles.css ├── img │ ├── external-link-icon.svg │ ├── favicon.ico │ ├── favicon.svg │ ├── matrix-lines-hero-sprite.svg │ └── opengraph.png └── js │ ├── .eslintrc.json │ ├── entry-client-hydrogen.js │ ├── entry-client-room-alias-hash-redirect.js │ ├── entry-client-room-directory.js │ └── entry-client-timeout.js ├── config ├── config.default.json └── config.test.json ├── docker-health-check.js ├── docs ├── faq.md ├── testing.md └── tracing.md ├── package-lock.json ├── package.json ├── server ├── child-process-runner │ ├── child-fork-script.js │ └── run-in-child-process.js ├── hydrogen-render │ ├── render-hydrogen-to-string-unsafe.js │ ├── render-hydrogen-to-string.js │ ├── render-hydrogen-vm-render-script-to-page-html.js │ └── render-page-html.js ├── lib │ ├── config.js │ ├── errors │ │ ├── extended-error.js │ │ ├── rethrown-error.js │ │ ├── route-timeout-abort-error.js │ │ ├── status-error.js │ │ └── user-closed-connection-abort-error.js │ ├── express-async-handler.js │ ├── fetch-endpoint.js │ ├── get-asset-url.js │ ├── get-dependencies-for-entry-point-name.js │ ├── get-version-tags.js │ ├── matrix-utils │ │ ├── ensure-room-joined.js │ │ ├── fetch-accessible-rooms.js │ │ ├── fetch-events-from-timestamp-backwards.js │ │ ├── fetch-room-data.js │ │ ├── get-messages-response-from-event-id.js │ │ ├── get-server-name-from-matrix-room-id-or-alias.js │ │ └── timestamp-to-event.js │ ├── parse-via-servers-from-user-input.js │ ├── safe-json.js │ ├── sanitize-html.js │ ├── set-headers-for-date-temporal-context.js │ └── set-headers-to-preload-assets.js ├── middleware │ ├── content-security-policy-middleware.js │ ├── identify-route-middleware.js │ ├── prevent-clickjacking-middleware.js │ ├── redirect-to-correct-room-url-if-bad-sigil-middleware.js │ └── timeout-middleware.js ├── routes │ ├── client-side-room-alias-hash-redirect-route.js │ ├── install-routes.js │ ├── room-directory-routes.js │ └── room-routes.js ├── server.js ├── start-dev.js └── tracing │ ├── capture-span-processor.js │ ├── serialize-span.js │ ├── trace-utilities.js │ ├── tracing-middleware.js │ └── tracing.js ├── shared ├── .eslintrc.json ├── hydrogen-vm-render-script.js ├── lib │ ├── assert.js │ ├── check-text-for-nsfw.js │ ├── custom-tile-utilities.js │ ├── local-storage-keys.js │ ├── matrix-viewer-history.js │ ├── mxc-url-to-http.js │ ├── redirect-if-room-alias-in-hash.js │ ├── reference-values.js │ ├── stub-powerlevels-observable.js │ ├── supress-blank-anchors-reloading-the-page.js │ ├── timestamp-utilities.js │ └── url-creator.js ├── room-directory-vm-render-script.js ├── viewmodels │ ├── AvatarViewModel.js │ ├── CalendarViewModel.js │ ├── DeveloperOptionsContentViewModel.js │ ├── HomeserverSelectionModalContentViewModel.js │ ├── JumpToNextActivitySummaryTileViewModel.js │ ├── JumpToPreviousActivitySummaryTileViewModel.js │ ├── ModalViewModel.js │ ├── RoomCardViewModel.js │ ├── RoomDirectoryViewModel.js │ ├── RoomViewModel.js │ └── TimeSelectorViewModel.js └── views │ ├── CalendarView.js │ ├── DeveloperOptionsContentView.js │ ├── HomeserverSelectionModalContentView.js │ ├── JumpToNextActivitySummaryTileView.js │ ├── JumpToPreviousActivitySummaryTileView.js │ ├── MatrixLogoView.js │ ├── ModalView.js │ ├── RightPanelContentView.js │ ├── RoomCardView.js │ ├── RoomDirectoryView.js │ ├── RoomView.js │ └── TimeSelectorView.js └── test ├── .eslintrc.json ├── docker-compose.yml ├── dockerfiles ├── Synapse.Dockerfile ├── keys │ ├── README.md │ ├── ca.crt │ └── ca.key └── synapse │ ├── as_registration.yaml │ ├── homeserver.yaml │ ├── log_config.yaml │ └── start.sh ├── e2e-tests.js ├── fixtures └── friction_between_surfaces.jpg ├── server └── matrix-utils │ └── get-server-name-from-matrix-room-id-or-alias-tests.js ├── shared └── lib │ ├── check-text-for-nsfw-tests.js │ └── timestamp-utilties-tests.js └── test-utils ├── client-utils.js ├── parse-matrix-viewer-url-for-room.js ├── parse-room-day-message-structure.js └── test-error.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "commonjs": true, 5 | "node": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2022, 9 | "sourceType": "script" 10 | }, 11 | "extends": ["eslint:recommended", "prettier", "plugin:n/recommended"], 12 | "rules": { 13 | "indent": "off", 14 | "comma-dangle": "off", 15 | "quotes": "off", 16 | "eqeqeq": ["warn", "allow-null"], 17 | "strict": ["error", "safe"], 18 | "no-unused-vars": ["error", { "destructuredArrayIgnorePattern": "^_" }], 19 | "no-extra-boolean-cast": ["warn"], 20 | "complexity": [ 21 | "error", 22 | { 23 | "max": 12 24 | } 25 | ], 26 | "max-statements-per-line": [ 27 | "error", 28 | { 29 | "max": 3 30 | } 31 | ], 32 | "no-debugger": "error", 33 | "no-dupe-keys": "error", 34 | "no-unsafe-finally": "error", 35 | "no-with": "error", 36 | "no-useless-call": "error", 37 | "no-spaced-func": "error", 38 | "no-useless-escape": "warn", 39 | "max-statements": ["warn", 30], 40 | "max-depth": ["error", 4], 41 | "no-throw-literal": ["error"], 42 | "no-sequences": "error", 43 | "radix": "error", 44 | "yoda": "error", 45 | "no-nested-ternary": "warn", 46 | "no-whitespace-before-property": "error", 47 | "no-trailing-spaces": ["error"], 48 | "space-in-parens": ["warn", "never"], 49 | "max-nested-callbacks": ["error", 6], 50 | "eol-last": "warn", 51 | "no-mixed-spaces-and-tabs": "error", 52 | "no-negated-condition": "warn", 53 | "no-unneeded-ternary": "error", 54 | "no-use-before-define": ["warn", { "variables": true, "functions": true, "classes": true }], 55 | "no-undef": "error", 56 | "no-param-reassign": "warn", 57 | "no-multi-spaces": [ 58 | "warn", 59 | { 60 | "exceptions": { 61 | "Property": true 62 | } 63 | } 64 | ], 65 | "key-spacing": [ 66 | "warn", 67 | { 68 | "singleLine": { 69 | "beforeColon": false, 70 | "afterColon": true 71 | }, 72 | "multiLine": { 73 | "beforeColon": false, 74 | "afterColon": true, 75 | "mode": "minimum" 76 | } 77 | } 78 | ], 79 | "n/no-process-exit": "off", 80 | "n/no-unsupported-features/es-syntax": "error", 81 | "n/no-unsupported-features/es-builtins": "error" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /.github/workflows/containerize.yaml: -------------------------------------------------------------------------------- 1 | name: Containerize 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | GHCR_NAMESPACE: matrix-org/matrix-viewer 10 | IMAGE_NAME: matrix-viewer 11 | 12 | jobs: 13 | # Create and publish a Docker image for matrix-viewer 14 | # 15 | # Based off of 16 | # https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-github-packages 17 | build-image: 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: read 21 | packages: write 22 | 23 | outputs: 24 | # Make the Docker image name available to use in other jobs as `${{ 25 | # needs.build-image.outputs.docker_image_name }}`. Also see 26 | # the `save_var` step below for how this works. 27 | docker_image_name: ${{ steps.save_var.outputs.docker_image_name }} 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v3 32 | 33 | - name: Log in to the GitHub Container registry 34 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | # Trick to get env variables available in `needs` context which is 41 | # available to use in almost everywhere 42 | # (https://docs.github.com/en/actions/learn-github-actions/contexts). This 43 | # is so that we can reference the same Docker image name in the `services` of 44 | # the next job. 45 | # 46 | # via https://github.community/t/how-to-use-env-with-container-image/17252/25 47 | - name: Save the Docker image name to a variable so we can share and re-use it in other jobs via `${{ needs.build-image.outputs.docker_image_name }}` 48 | id: save_var 49 | run: echo "::set-output name=docker_image_name::${{ env.REGISTRY }}/${{ env.GHCR_NAMESPACE }}/${{ env.IMAGE_NAME }}" 50 | 51 | - name: Extract metadata (tags, labels) for Docker 52 | id: meta 53 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 54 | with: 55 | images: ${{ steps.save_var.outputs.docker_image_name }} 56 | # Defaults (as indicated by https://github.com/docker/metadata-action/tree/97c170d70b5f8bfc77e6b23e68381f217cb64ded#tags-input). 57 | # Plus custom tags: 58 | # - Full length sha 59 | tags: | 60 | type=schedule 61 | type=ref,event=branch 62 | type=ref,event=tag 63 | type=ref,event=pr 64 | type=sha,format=long 65 | 66 | - name: Build and push Docker image 67 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 68 | with: 69 | push: true 70 | context: '.' 71 | file: 'Dockerfile' 72 | tags: ${{ steps.meta.outputs.tags }} 73 | labels: ${{ steps.meta.outputs.labels }} 74 | build-args: | 75 | GITHUB_SHA=${{ github.sha }} 76 | GITHUB_REF=${{ github.ref_name }} 77 | 78 | # Just make sure the container can start-up and responds to the health check 79 | test-image: 80 | needs: [build-image] 81 | runs-on: ubuntu-latest 82 | 83 | services: 84 | matrix-viewer: 85 | image: ${{ needs.build-image.outputs.docker_image_name }}:sha-${{ github.sha }} 86 | credentials: 87 | username: ${{ github.actor }} 88 | password: ${{ secrets.GITHUB_TOKEN }} 89 | ports: 90 | - 3050:3050 91 | env: 92 | matrixServerUrl: http://FAKE_SERVER/ 93 | matrixAccessToken: FAKE_TOKEN 94 | 95 | steps: 96 | - name: See if the container will respond to a request 97 | run: curl http://localhost:3050/health-check 98 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | lint-eslint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | cache: 'npm' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Run ESLint 24 | run: npm run eslint -- "**/*.js" 25 | 26 | lint-prettier: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v3 30 | - name: Use Node.js ${{ matrix.node-version }} 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: ${{ matrix.node-version }} 34 | cache: 'npm' 35 | 36 | - name: Install dependencies 37 | run: npm ci 38 | 39 | - name: Run Prettier 40 | run: npm run prettier -- --check "**/*.{js,css,md}" 41 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | GHCR_NAMESPACE: matrix-org/matrix-viewer 10 | IMAGE_NAME: matrix-viewer-test-homeserver 11 | 12 | jobs: 13 | # Create and publish a Docker image for a Synapse test instance that can 14 | # federate with each other. 15 | # 16 | # Based off of 17 | # https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-github-packages 18 | build-test-synapse-image: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: read 22 | packages: write 23 | 24 | outputs: 25 | # Make the Docker image name available to use in other jobs as `${{ 26 | # needs.build-test-synapse-image.outputs.docker_image_name }}`. Also see 27 | # the `save_var` step below for how this works. 28 | docker_image_name: ${{ steps.save_var.outputs.docker_image_name }} 29 | 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@v3 33 | 34 | - name: Log in to the GitHub Container registry 35 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 36 | with: 37 | registry: ${{ env.REGISTRY }} 38 | username: ${{ github.actor }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | # Trick to get env variables available in `needs` context which is 42 | # available to use in almost everywhere 43 | # (https://docs.github.com/en/actions/learn-github-actions/contexts). This 44 | # is so that we can reference the same Docker image name in the `services` of 45 | # the next job. 46 | # 47 | # via https://github.community/t/how-to-use-env-with-container-image/17252/25 48 | - name: Save the Docker image name to a variable so we can share and re-use it in other jobs via `${{ needs.build-test-synapse-image.outputs.docker_image_name }}` 49 | id: save_var 50 | run: echo "::set-output name=docker_image_name::${{ env.REGISTRY }}/${{ env.GHCR_NAMESPACE }}/${{ env.IMAGE_NAME }}" 51 | 52 | - name: Extract metadata (tags, labels) for Docker 53 | id: meta 54 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 55 | with: 56 | images: ${{ steps.save_var.outputs.docker_image_name }} 57 | # Defaults (as indicated by https://github.com/docker/metadata-action/tree/97c170d70b5f8bfc77e6b23e68381f217cb64ded#tags-input). 58 | # Plus custom tags: 59 | # - Full length sha 60 | tags: | 61 | type=schedule 62 | type=ref,event=branch 63 | type=ref,event=tag 64 | type=ref,event=pr 65 | type=sha,format=long 66 | 67 | - name: Build and push Docker image 68 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 69 | with: 70 | push: true 71 | context: test/dockerfiles/ 72 | file: 'test/dockerfiles/Synapse.Dockerfile' 73 | tags: ${{ steps.meta.outputs.tags }} 74 | labels: ${{ steps.meta.outputs.labels }} 75 | 76 | tests: 77 | needs: [build-test-synapse-image] 78 | runs-on: ubuntu-latest 79 | 80 | strategy: 81 | matrix: 82 | node-version: [18.x] 83 | 84 | services: 85 | # We need two homeservers that federate with each other to test with 86 | hs1: 87 | image: ${{ needs.build-test-synapse-image.outputs.docker_image_name }}:sha-${{ github.sha }} 88 | credentials: 89 | username: ${{ github.actor }} 90 | password: ${{ secrets.GITHUB_TOKEN }} 91 | ports: 92 | - 11008:8008 93 | env: 94 | SERVER_NAME: hs1 95 | hs2: 96 | image: ${{ needs.build-test-synapse-image.outputs.docker_image_name }}:sha-${{ github.sha }} 97 | credentials: 98 | username: ${{ github.actor }} 99 | password: ${{ secrets.GITHUB_TOKEN }} 100 | ports: 101 | - 12008:8008 102 | env: 103 | SERVER_NAME: hs2 104 | 105 | steps: 106 | - uses: actions/checkout@v3 107 | - name: Use Node.js ${{ matrix.node-version }} 108 | uses: actions/setup-node@v3 109 | with: 110 | node-version: ${{ matrix.node-version }} 111 | cache: 'npm' 112 | 113 | - name: Install dependencies 114 | run: npm ci 115 | 116 | - name: Run build 117 | run: npm run build 118 | 119 | - name: Test! 120 | run: npm test 121 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | public 5 | *.local 6 | 7 | config.user-overrides.json 8 | config/config.dev.user-overrides.json 9 | config/config.beta.user-overrides.json 10 | config/config.prod.user-overrides.json 11 | config.json 12 | secrets.json 13 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.0 - _upcoming_ - Matrix Viewer 2 | 3 | - Prevent Cloudflare from overriding our own 504 timeout page, https://github.com/matrix-org/matrix-viewer/pull/228 4 | - Catch NSFW rooms with underscores, https://github.com/matrix-org/matrix-viewer/pull/231 5 | - Fix `18+` false positives with NSFW check, https://github.com/matrix-org/matrix-viewer/pull/279 6 | - Fix room cards sorting in the wrong direction on Firefox, https://github.com/matrix-org/matrix-viewer/pull/261 7 | - Remove `libera.chat` as a default since their rooms are not accessible, https://github.com/matrix-org/matrix-viewer/pull/263 8 | - Add reason why the bot is joining the room, https://github.com/matrix-org/matrix-viewer/pull/262 9 | - Add `/faq` redirect, https://github.com/matrix-org/matrix-viewer/pull/265 10 | - Use `rel=canonical` link to de-duplicate event permalinks, https://github.com/matrix-org/matrix-viewer/pull/266, https://github.com/matrix-org/matrix-viewer/pull/269 11 | - Prevent join event spam with stable `reason`, https://github.com/matrix-org/matrix-viewer/pull/268 12 | - Don't allow previewing `shared` history rooms, https://github.com/matrix-org/matrix-viewer/pull/239 13 | - Contributed by [@tulir](https://github.com/tulir) 14 | - Update FAQ to explain `world_readable` only, https://github.com/matrix-org/matrix-viewer/pull/277 15 | - Indicate when the room was set to `world_readable` and by who, https://github.com/matrix-org/matrix-viewer/pull/278 16 | - Only show `world_readable` rooms in the room directory, https://github.com/matrix-org/matrix-viewer/pull/276 17 | 18 | Developer facing: 19 | 20 | - Fix eslint trying to look at `node_modules/`, https://github.com/matrix-org/matrix-viewer/pull/275 21 | 22 | # 0.1.0 - 2023-05-11 23 | 24 | - Initial public release with good enough functionality to be generally available including: room directory homepage, room archive view with calendar jump-to-date, drill-down with the time selector, following room upgrades (tombstone/predecessor), and more. Completed milestone: https://github.com/matrix-org/matrix-viewer/milestone/1 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # XXX: Before updating this, make sure the issues around `npm` silently exiting 2 | # with error 243 issues are solved: 3 | # - https://github.com/npm/cli/issues/4996 4 | # - https://github.com/npm/cli/issues/4769 5 | FROM node:18.16.0-buster-slim 6 | 7 | # Pass through some GitHub CI variables which we use in the build (for version 8 | # files/tags) 9 | ARG GITHUB_SHA 10 | ENV GITHUB_SHA=$GITHUB_SHA 11 | ARG GITHUB_REF 12 | ENV GITHUB_REF=$GITHUB_REF 13 | 14 | RUN mkdir -p /app 15 | 16 | WORKDIR /app 17 | 18 | # Copy the health-check script 19 | COPY docker-health-check.js /app/ 20 | 21 | # Copy just what we need to install the dependencies so this layer can be cached 22 | # in the Docker build 23 | COPY package.json package-lock.json /app/ 24 | RUN npm install 25 | 26 | # Copy what we need for the client-side build 27 | COPY config /app/config/ 28 | COPY build-scripts /app/build-scripts/ 29 | COPY client /app/client/ 30 | COPY shared /app/shared/ 31 | # Also copy the server stuff (we reference the config from the `build-client.js`) 32 | COPY server /app/server/ 33 | # Build the client-side bundle 34 | RUN npm run build 35 | 36 | HEALTHCHECK CMD node docker-health-check.js 37 | 38 | ENTRYPOINT ["/bin/bash", "-c", "npm start"] 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Matrix Viewer 2 | 3 | Join the community and get support at #matrix-viewer:matrix.org 4 | 5 | > **Note** 6 | > The Matrix Public Archive has been renamed to Matrix Viewer to better reflect what it 7 | > actually does and doesn't do. It's a viewer for world-readable Matrix rooms and 8 | > doesn't actually archive anything. 9 | 10 | In the vein of [feature parity with 11 | Gitter](https://github.com/vector-im/roadmap/issues/26), the goal is to make an 12 | accessible public site for `world_readable` Matrix rooms like Gitter's archives 13 | which search engines can index and keep all of the content accessible/available. 14 | 15 | 16 | Room directory homepage | Room view 17 | --- | --- 18 | A reference for how the Matrix Viewer homepage looks. Search bar where you can find thousands of rooms using Matrix and homeserver selector. Grid of room cards showing the results. | ![A reference for how the Matrix Viewer looks. Showing off a day of messages in `#gitter:matrix.org` on 2021-08-06. There is a date picker calendar in the right sidebar and a traditional chat app layout on the left.](https://user-images.githubusercontent.com/558581/234765275-28c70c49-c27f-473a-88ba-f4392ddae871.png) 19 | 20 | ## Demo videos 21 | 22 | The demo's refer to this project as the "Matrix Public Archive" which has now been renamed to "Matrix Viewer". 23 | 24 | - [![](https://user-images.githubusercontent.com/558581/206083768-d18456de-caa3-463f-a891-96eed8054686.png) May 2023](https://www.youtube.com/watch?v=4KlNILNItGQ&t=1046s): Introducing [archive.matrix.org](https://archive.matrix.org/), the shiny new public instance of the Matrix Public Archive that everyone can share and link to. 25 | - [![](https://user-images.githubusercontent.com/558581/206083768-d18456de-caa3-463f-a891-96eed8054686.png) Aug 2022](https://www.youtube.com/watch?v=6KHQSeJTXm0&t=583s) ([blog post](https://matrix.org/blog/2022/08/05/this-week-in-matrix-2022-08-05#matrix-public-archive-website)): A quick intro of what the project looks like, the goals, what it accomplishes, and how it's a new portal into the Matrix ecosystem. 26 | - [![](https://user-images.githubusercontent.com/558581/206083768-d18456de-caa3-463f-a891-96eed8054686.png) Oct 2022](https://www.youtube.com/watch?v=UT6KSEqDUf8&t=548s): Showing off the room directory landing page used to browse everything available in the archive. 27 | 28 | ## Technical overview 29 | 30 | We server-side render (SSR) the [Hydrogen](https://github.com/vector-im/hydrogen-web) 31 | Matrix client on a Node.js server (since both use JavaScript) and serve pages on the fly 32 | (with some Cloudflare caching on top) when someone requests 33 | `/r/matrixhq:matrix.org/${year}/${month}/${day}`. To fetch the events for a 34 | given day/time, we use [MSC3030](https://github.com/matrix-org/matrix-doc/pull/3030)'s 35 | `/timestamp_to_event` endpoint to jump to a given day in the timeline and fetch the 36 | messages from a Matrix homeserver. 37 | 38 | Re-using Hydrogen gets us pretty and native(to Element) looking styles and keeps 39 | the maintenance burden of supporting more event types in Hydrogen. 40 | 41 | ## FAQ 42 | 43 | See the [FAQ page](docs/faq.md). 44 | 45 | ## Setup 46 | 47 | ### Prerequisites 48 | 49 | - [Node.js](https://nodejs.org/) v18 50 | - We need v18 because it includes `fetch` by default. And [`node-fetch` doesn't 51 | support `abortSignal.reason`](https://github.com/node-fetch/node-fetch/issues/1462) 52 | yet. 53 | - We need v16 because it includes 54 | [`require('crypto').webcrypto.subtle`](https://nodejs.org/docs/latest-v16.x/api/webcrypto.html#cryptosubtle) 55 | for [Matrix encryption (olm) which can't be disabled in 56 | Hydrogen](https://github.com/vector-im/hydrogen-web/issues/579) yet. And 57 | [`abortSignal.reason` was introduced in 58 | v16.14.0](https://nodejs.org/dist/latest-v18.x/docs/api/globals.html#abortsignalreason) (although we use `node-fetch` for now). 59 | - A Matrix homeserver that supports [MSC3030's](https://github.com/matrix-org/matrix-spec-proposals/pull/3030) `/timestamp_to_event` endpoint 60 | - [Synapse](https://matrix.org/docs/projects/server/synapse) 1.73.0+ 61 | 62 | ### Get the app running 63 | 64 | ```sh 65 | $ npm install 66 | $ npm run build 67 | 68 | # Edit `config/config.user-overrides.json` so that `matrixServerUrl` points to 69 | # your homeserver and has `matrixAccessToken` defined 70 | $ cp config/config.default.json config/config.user-overrides.json 71 | 72 | $ npm run start 73 | ``` 74 | 75 | ## Development 76 | 77 | ```sh 78 | # Clone and install the `matrix-viewer` project 79 | $ git clone git@github.com:matrix-org/matrix-viewer.git 80 | $ cd matrix-viewer 81 | $ npm install 82 | 83 | # Edit `config/config.user-overrides.json` so that `matrixServerUrl` points to 84 | # your homeserver and has `matrixAccessToken` defined 85 | $ cp config/config.default.json config/config.user-overrides.json 86 | 87 | # This will watch for changes, rebuild bundles and restart the server 88 | $ npm run start-dev 89 | ``` 90 | 91 | If you want to make changes to the underlying Hydrogen SDK as well, you can locally link 92 | it into this project with the following instructions: 93 | 94 | ```sh 95 | # We need to use a draft branch of Hydrogen to get the custom changes needed for 96 | # `matrix-viewer` to run. Hopefully soon, we can get all of the custom 97 | # changes mainlined so this isn't necessary. 98 | $ git clone git@github.com:vector-im/hydrogen-web.git 99 | $ cd hydrogen-web 100 | $ git checkout madlittlemods/matrix-public-archive-scratch-changes 101 | $ yarn install 102 | $ yarn build:sdk 103 | $ cd target/ && npm link && cd .. 104 | $ cd .. 105 | 106 | $ cd matrix-viewer 107 | $ npm link hydrogen-view-sdk 108 | ``` 109 | 110 | ### Running tests 111 | 112 | See the [testing documentation](./docs/testing.md). 113 | 114 | ### Tracing 115 | 116 | See the [tracing documentation](./docs/tracing.md). 117 | -------------------------------------------------------------------------------- /build-scripts/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "n/no-unpublished-require": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /build-scripts/build-client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const vite = require('vite'); 4 | const mergeOptions = require('merge-options'); 5 | 6 | // Require the config before the Vite config so `process.env.NODE_ENV` is set 7 | require('../server/lib/config'); 8 | 9 | const writeVersionFiles = require('./write-version-files'); 10 | const viteConfig = require('./vite.config'); 11 | 12 | async function buildClient(extraConfig = {}) { 13 | await writeVersionFiles(); 14 | 15 | const resultantViteConfig = mergeOptions(viteConfig, extraConfig); 16 | await vite.build(resultantViteConfig); 17 | } 18 | 19 | module.exports = buildClient; 20 | -------------------------------------------------------------------------------- /build-scripts/do-client-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is just a callable from the commandline version of `build-client.js`. So 4 | // that we can run the build from an npm script. 5 | 6 | const buildClient = require('./build-client'); 7 | 8 | buildClient(); 9 | -------------------------------------------------------------------------------- /build-scripts/vite.config.js: -------------------------------------------------------------------------------- 1 | // vite.config.js 2 | 'use strict'; 3 | 4 | const path = require('path'); 5 | const { 6 | defineConfig, //splitVendorChunkPlugin 7 | } = require('vite'); 8 | 9 | module.exports = defineConfig({ 10 | // We have to specify this otherwise Vite will override NODE_ENV as 11 | // `production` when we start the server and watch build in our `start-dev.js`. 12 | mode: process.env.NODE_ENV || 'dev', 13 | 14 | plugins: [ 15 | // Alternatively, we can manually configure chunks via 16 | // `build.rollupOptions.output.manualChunks`. 17 | // Docs: https://vitejs.dev/guide/build.html#chunking-strategy 18 | // 19 | // This didn't seem to work for me though, so I've done the manual config way. 20 | // splitVendorChunkPlugin(), 21 | ], 22 | 23 | optimizeDeps: { 24 | include: [ 25 | // This doesn't seem to be necessary for the this package to work (ref 26 | // https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies) 27 | // 28 | //'matrix-viewer-shared' 29 | ], 30 | }, 31 | resolve: { 32 | alias: { 33 | // The `file:` packages don't seem resolve correctly so let's add an alias as well 34 | // See https://css-tricks.com/adding-vite-to-your-existing-web-app/#aa-aliases 35 | 'matrix-viewer-shared': path.resolve(__dirname, '../shared'), 36 | }, 37 | // This will make sure Vite/Rollup matches the original file path (i.e. the path 38 | // without following symlinks) instead of the real file path (i.e. the path after 39 | // following symlinks). This is useful when symlinking `hydrogen-view-sdk`, so it 40 | // still matches our `/node_modules/` pattern in the `build.commonjsOptions.include` 41 | // config below and gets converted from CommonJS to ESM as expected. 42 | preserveSymlinks: true, 43 | }, 44 | build: { 45 | outDir: './dist', 46 | rollupOptions: { 47 | // Overwrite default `index.html` entry 48 | // (https://vitejs.dev/guide/backend-integration.html#backend-integration) 49 | input: [ 50 | path.resolve(__dirname, '../client/js/entry-client-hydrogen.js'), 51 | path.resolve(__dirname, '../client/js/entry-client-room-directory.js'), 52 | path.resolve(__dirname, '../client/js/entry-client-room-alias-hash-redirect.js'), 53 | path.resolve(__dirname, '../client/js/entry-client-timeout.js'), 54 | ], 55 | output: { 56 | assetFileNames: (chunkInfo) => { 57 | const { name } = path.parse(chunkInfo.name); 58 | // Some of the Hydrogen assets already have hashes in the name so let's remove 59 | // that in favor of our new hash. 60 | const nameWithoutHash = name.replace(/-[a-z0-9]+$/, ''); 61 | 62 | return `assets/${nameWithoutHash}-[hash][extname]`; 63 | }, 64 | }, 65 | }, 66 | 67 | // We want to know how the transformed source relates back to the original source 68 | // for easier debugging 69 | sourcemap: true, 70 | 71 | // Generate `dist/manifest.json` that we can use to map a given file to it's built 72 | // hashed file name and any dependencies it has. 73 | manifest: true, 74 | // We don't want to use the `ssrManifest` option. It's supposedly "for determining 75 | // style links and asset preload directives in production" 76 | // (https://vitejs.dev/config/build-options.html#build-ssrmanifest) (also see 77 | // https://vitejs.dev/guide/ssr.html#generating-preload-directives) but doesn't seem 78 | // very useful or what we want. 79 | // 80 | // ssrManifest: true, 81 | 82 | // Copy things like the version files from `public/` to `dist/`. Things in `public/` 83 | // are copied as-is with no transformations. 84 | copyPublicDir: true, 85 | 86 | commonjsOptions: { 87 | include: [ 88 | // Fix `Error: 'default' is not exported by ...` when importing CommonJS files, see 89 | // https://github.com/vitejs/vite/issues/2679 and docs: 90 | // https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies 91 | /shared\//, 92 | 93 | // Make all of our `require()` CommonJS calls compatible in the ESM client build. 94 | // See https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies 95 | /node_modules/, 96 | ], 97 | }, 98 | }, 99 | }); 100 | -------------------------------------------------------------------------------- /build-scripts/write-version-files.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const { mkdir, writeFile } = require('fs').promises; 5 | const util = require('util'); 6 | const exec = util.promisify(require('child_process').exec); 7 | 8 | async function mkdirp(path) { 9 | try { 10 | await mkdir(path, { recursive: true }); 11 | } catch (err) { 12 | console.log('mkdirp err', err); 13 | // no-op, the directory is already created 14 | } 15 | } 16 | 17 | async function writeVersionFiles() { 18 | let commit; 19 | let branch; 20 | try { 21 | commit = (await exec(`git rev-parse HEAD`)).stdout; 22 | branch = (await exec(`git rev-parse --abbrev-ref HEAD`)).stdout; 23 | } catch (err) { 24 | console.log( 25 | `Failed to use \`git\` to find the commit and branch.` + 26 | ` Falling back to using environment variables assuming we're running in GitHub CI. The error encountered:`, 27 | err 28 | ); 29 | 30 | // Pull these values from environment variables provided by GitHub CI 31 | commit = process.env.GITHUB_SHA; 32 | branch = process.env.GITHUB_REF; 33 | } 34 | 35 | if (!commit || !branch) { 36 | throw new Error( 37 | `Unable to get a suitable commit=${commit} or branch=${branch} while writing version files` 38 | ); 39 | } 40 | 41 | await mkdirp(path.join(__dirname, '../public/')); 42 | await writeFile(path.join(__dirname, '../public/GIT_COMMIT'), commit); 43 | await writeFile(path.join(__dirname, '../public/VERSION'), branch); 44 | await writeFile(path.join(__dirname, '../public/VERSION_DATE'), new Date().toISOString()); 45 | } 46 | 47 | module.exports = writeVersionFiles; 48 | -------------------------------------------------------------------------------- /client/img/external-link-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /client/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-viewer/5c448f24c252ebc4068824f1e1e47cefe7527b5f/client/img/favicon.ico -------------------------------------------------------------------------------- /client/img/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /client/img/opengraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-viewer/5c448f24c252ebc4068824f1e1e47cefe7527b5f/client/img/opengraph.png -------------------------------------------------------------------------------- /client/js/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": false, 5 | "node": false 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2018, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "n/no-unsupported-features/es-syntax": "off", 13 | "n/no-unsupported-features/es-builtins": "off" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/js/entry-client-hydrogen.js: -------------------------------------------------------------------------------- 1 | import 'matrix-viewer-shared/hydrogen-vm-render-script.js'; 2 | 3 | // Assets 4 | import 'hydrogen-view-sdk/assets/theme-element-light.css'; 5 | import '../css/styles.css'; 6 | -------------------------------------------------------------------------------- /client/js/entry-client-room-alias-hash-redirect.js: -------------------------------------------------------------------------------- 1 | import assert from 'matrix-viewer-shared/lib/assert.js'; 2 | import MatrixViewerURLCreator from 'matrix-viewer-shared/lib/url-creator.js'; 3 | import redirectIfRoomAliasInHash from 'matrix-viewer-shared/lib/redirect-if-room-alias-in-hash.js'; 4 | 5 | // Assets 6 | import 'hydrogen-view-sdk/assets/theme-element-light.css'; 7 | import '../css/styles.css'; 8 | 9 | const config = window.matrixViewerContext.config; 10 | assert(config); 11 | assert(config.basePath); 12 | 13 | const matrixViewerURLCreator = new MatrixViewerURLCreator(config.basePath); 14 | 15 | console.log(`Trying to redirect based on pageHash=${window.location.hash}`); 16 | const isRedirecting = redirectIfRoomAliasInHash(matrixViewerURLCreator); 17 | 18 | // Show the message while we're trying to redirect or if we found nothing, remove the 19 | // message 20 | document.querySelector('.js-try-redirect-message').style.display = isRedirecting 21 | ? 'inline' 22 | : 'none'; 23 | -------------------------------------------------------------------------------- /client/js/entry-client-room-directory.js: -------------------------------------------------------------------------------- 1 | import 'matrix-viewer-shared/room-directory-vm-render-script.js'; 2 | 3 | // Assets 4 | import 'hydrogen-view-sdk/assets/theme-element-light.css'; 5 | import '../css/styles.css'; 6 | import '../css/room-directory.css'; 7 | // Just need to reference the favicon in one of the entry points for it to be copied 8 | // over for all 9 | import '../img/favicon.ico'; 10 | import '../img/favicon.svg'; 11 | import '../img/opengraph.png'; 12 | -------------------------------------------------------------------------------- /client/js/entry-client-timeout.js: -------------------------------------------------------------------------------- 1 | // Assets 2 | import 'hydrogen-view-sdk/assets/theme-element-light.css'; 3 | import '../css/styles.css'; 4 | -------------------------------------------------------------------------------- /config/config.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "basePort": "3050", 3 | "basePath": "http://localhost:3050", 4 | // Requires homeserver with MSC3030 `/timestamp_to_event` endpoint (Synapse 1.73.0+) 5 | // (see readme for more details) 6 | "matrixServerUrl": "http://localhost:8008/", 7 | "matrixServerName": "localhost", 8 | // Set this to 100 since that is the max that Synapse will backfill even if you do a 9 | // `/messages?limit=1000` and we don't want to miss messages in between. 10 | "messageLimit": 100, 11 | "requestTimeoutMs": 25000, 12 | "logOutputFromChildProcesses": false, 13 | //"stopSearchEngineIndexing": true, 14 | "workaroundCloudflare504TimeoutErrors": false, 15 | // Tracing 16 | //"jaegerTracesEndpoint": "http://localhost:14268/api/traces", 17 | 18 | // Testing 19 | "testMatrixServerUrl1": "http://localhost:11008/", 20 | "testMatrixServerUrl2": "http://localhost:12008/" 21 | 22 | // Secrets 23 | //"matrixAccessToken": "xxx" 24 | } 25 | -------------------------------------------------------------------------------- /config/config.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "basePort": "3051", 3 | "basePath": "http://localhost:3051", 4 | "matrixServerUrl": "http://localhost:11008/", 5 | "matrixServerName": "hs1", 6 | "testMatrixServerUrl1": "http://localhost:11008/", 7 | "testMatrixServerUrl2": "http://localhost:12008/", 8 | 9 | // Secrets 10 | "matrixAccessToken": "as_token_matrix_viewer_foobarbaz" 11 | } 12 | -------------------------------------------------------------------------------- /docker-health-check.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | 5 | const { fetchEndpointAsJson } = require('./server/lib/fetch-endpoint'); 6 | 7 | const config = require('./server/lib/config'); 8 | const basePort = config.get('basePort'); 9 | assert(basePort); 10 | 11 | const healthCheckUrl = `http://localhost:${basePort}/health-check`; 12 | 13 | (async () => { 14 | try { 15 | await fetchEndpointAsJson(healthCheckUrl); 16 | process.exit(0); 17 | } catch (err) { 18 | // eslint-disable-next-line no-console 19 | console.log(`Health check error: ${healthCheckUrl}`, err); 20 | process.exit(1); 21 | } 22 | })(); 23 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## Can I run my own instance? 4 | 5 | Yes! We host a public canonical version of the Matrix Viewer at 6 | _(incoming)_ that everyone can use but feel free to 7 | also run your own instance (setup instructions in the [readme](../README.md)). 8 | 9 | ## How is this different from the `matrix-static` project? 10 | 11 | [Matrix Static](https://github.com/matrix-org/matrix-static) already existed 12 | before the Matrix Viewer but there was some desire to make something with more 13 | Element-feeling polish and loading faster (avoid the slow 502's errors that are frequent 14 | on `view.matrix.org`). 15 | 16 | And with the introduction of the jump to date API via 17 | [MSC3030](https://github.com/matrix-org/matrix-spec-proposals/pull/3030), we could show 18 | messages from any given date and day-by-day navigation. 19 | 20 | The Matrix Viewer project has since replaced the `matrix-static` project on 21 | [`view.matrix.org`](https://view.matrix.org/). 22 | 23 | ## Why did the bot join my room? 24 | 25 | Only Matrix rooms with `world_readable` [history 26 | visibility](https://spec.matrix.org/latest/client-server-api/#room-history-visibility) 27 | are accessible in the Matrix Viewer and indexed by search engines. 28 | 29 | But the bot (`@view:matrix.org`) will join any public room because it doesn't 30 | know the history visibility without first joining. Any room that doesn't have 31 | `world_readable` history visibility will lead a `403 Forbidden`. 32 | 33 | The Matrix Viewer hold onto any data (it's 34 | stateless) and requests the messages from the homeserver every time. The 35 | [view.matrix.org](https://view.matrix.org/) instance has some caching in place, 5 36 | minutes for the current day, and 2 days for past content. 37 | 38 | See the [opt out 39 | section](#how-do-i-opt-out-and-keep-my-room-from-being-indexed-by-search-engines) below 40 | for more details. 41 | 42 | ## How do I opt out and keep my room from being indexed by search engines? 43 | 44 | Only Matrix rooms with `world_readable` [history 45 | visibility](https://spec.matrix.org/latest/client-server-api/#room-history-visibility) 46 | are accessible in the Matrix Viewer and indexed by search engines. One easy way 47 | to opt-out is to change your rooms history visibility to something else if you don't 48 | intend for your room be world readable. 49 | 50 | Dedicated opt-out controls are being tracked in 51 | [#47](https://github.com/matrix-org/matrix-viewer/issues/47). 52 | 53 | As a workaround for [view.matrix.org](https://view.matrix.org/), you can ban the 54 | `@view:matrix.org` user if you don't want your room content to be shown at all. 55 | 56 | ### Why does the bot user join rooms instead peeking in the room or using guests? 57 | 58 | Since Matrix Viewer only displays rooms with `world_readable` history visibility, we could 59 | peek into the rooms without joining. This is being explored in 60 | [#272](https://github.com/matrix-org/matrix-viewer/pull/272). But peeking 61 | doesn't work when the server doesn't know about the room already (this is commonly 62 | referred to as federated peeking) which is why we have to fallback to joining the room 63 | in any case. We could solve the federated peeking problem and avoid the join with 64 | [MSC3266 room summaries](https://github.com/matrix-org/matrix-spec-proposals/pull/3266) 65 | to check whether the room is `world_readable` even over federation. 66 | 67 | Guests are completely separate concept and controlled by the `m.room.guest_access` state 68 | event in the room. Guest access is also a much different ask than read-only access since 69 | guests can also send messages in the room which isn't always desirable. The bot 70 | is read-only and does not send messages. 71 | 72 | ## Technical details 73 | 74 | The main readme has a [technical overview](../README.md#technical-overview) of the 75 | project. Here are a few more details. 76 | 77 | ### How do I figure out what version of the Matrix Viewer is running? 78 | 79 | Just visit the `/health-check` endpoint which will return information like the following: 80 | 81 | ``` 82 | { 83 | "ok": true, 84 | "commit": "954b22995a44bf11bfcd5850b62e206e46ee2db9", 85 | "version": "main", 86 | "versionDate": "2023-04-05T09:26:12.524Z", 87 | "packageVersion": "0.0.0" 88 | } 89 | ``` 90 | 91 | ### How does the room URL relate to what is displayed on the page? 92 | 93 | We start the end of the date/time specified in the URL looking backward up to the limit. 94 | 95 | ### Why does the time selector only appear for some pages? 96 | 97 | The time selector only appears for pages that have a lot of messages on a given 98 | day/hour/minute/second (more than the configured `messageLimit`). 99 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## Setup 4 | 5 | If you haven't setup `matrix-viewer` yet, see the [_Setup_ section in the root `README.md`](../README.md#setup) 6 | 7 | Then we need to setup the federation cluster of homeservers that we will test with. 8 | Sorry, this isn't automated yet when you run the tests 🙇 9 | 10 | ```sh 11 | # Build the test homeserver image that are pre-configured to federate with each other 12 | $ docker pull matrixdotorg/synapse:latest 13 | $ docker build -t matrix-viewer-test-homeserver -f test/dockerfiles/Synapse.Dockerfile test/dockerfiles/ 14 | 15 | # Start the test homeservers 16 | $ docker-compose --project-name matrix_viewer_test -f test/docker-compose.yml up -d --no-recreate 17 | ``` 18 | 19 | ## Running the tests 20 | 21 | ```sh 22 | $ npm run test 23 | ``` 24 | 25 | Or if you want to keep the Matrix Viewer server running after the tests run and 26 | explore the UI from the interactive URL's printed on the screen to better debug, use: 27 | 28 | ```sh 29 | $ npm run test-interactive 30 | ``` 31 | 32 | Caveat: You might not see the same result that a test is seeing when visiting the 33 | interactive URL. Some tests set config like the `messageLimit` which is reset 34 | after each test case unless you are using `npm run test-interactive` and visiting the 35 | interactive URL for a failed test. Otherwise, we reset config between each test case so 36 | they don't leak and contaminate each other. 37 | 38 | ### Developer utility 39 | 40 | Some copy-pasta to help you manage the Docker containers for the test homeservers: 41 | 42 | ```sh 43 | $ docker ps --all | grep test_hs 44 | $ docker logs -f --tail 10 matrix_viewer_test_hs1_1 45 | $ docker logs -f --tail 10 matrix_viewer_test_hs2_1 46 | 47 | $ docker stop matrix_viewer_test_hs1_1 matrix_viewer_test_hs2_1 48 | $ docker rm matrix_viewer_test_hs1_1 matrix_viewer_test_hs2_1 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/tracing.md: -------------------------------------------------------------------------------- 1 | # Tracing 2 | 3 | Tracing allows you to see the flow of a request through the system and where time is 4 | taken up in functions. This is useful for debugging and performance analysis. 5 | 6 | 7 | 8 | ## Setup 9 | 10 | 1. Get the all-in-one Jaeger Docker container running (via https://www.jaegertracing.io/docs/1.35/getting-started/) 11 | ``` 12 | docker run -d --name jaeger \ 13 | -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 \ 14 | -e COLLECTOR_OTLP_ENABLED=true \ 15 | -p 6831:6831/udp \ 16 | -p 6832:6832/udp \ 17 | -p 5778:5778 \ 18 | -p 5775:5775/udp \ 19 | -p 16686:16686 \ 20 | -p 4317:4317 \ 21 | -p 4318:4318 \ 22 | -p 14250:14250 \ 23 | -p 14268:14268 \ 24 | -p 14269:14269 \ 25 | -p 9411:9411 \ 26 | jaegertracing/all-in-one:1.35 27 | ``` 28 | 1. Add `jaegerTracesEndpoint` to your `config.json`: 29 | ```json5 30 | { 31 | // ... 32 | jaegerTracesEndpoint: 'http://localhost:14268/api/traces', 33 | } 34 | ``` 35 | 36 | ## Run the app with the OpenTelemetry tracing enabled 37 | 38 | ``` 39 | npm run start -- --tracing 40 | # or 41 | npm run start-dev -- --tracing 42 | ``` 43 | 44 | Manually: 45 | 46 | ``` 47 | node --require './server/tracing.js' server/server.js 48 | ``` 49 | 50 | ## Viewing traces in Jaeger 51 | 52 | Once you have the all-in-one Jaeger Docker container running, just visit 53 | http://localhost:16686 to see a dashboard of the collected traces and dive in. 54 | 55 | Traces are made up of many spans. Each span defines a `traceId` which it is associated with. 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matrix-viewer", 3 | "version": "0.1.0", 4 | "license": "Apache-2.0", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/matrix-org/matrix-viewer" 8 | }, 9 | "scripts": { 10 | "lint": "eslint \"**/*.js\"", 11 | "build": "node ./build-scripts/do-client-build.js", 12 | "start": "node server/server.js", 13 | "start-dev": "node server/start-dev.js", 14 | "test": "npm run mocha -- test/**/*-tests.js --timeout 15000", 15 | "test-e2e-interactive": "npm run mocha -- test/e2e-tests.js --timeout 15000 --bail --interactive", 16 | "nodemon": "nodemon", 17 | "gulp": "gulp", 18 | "vite": "vite", 19 | "mocha": "mocha", 20 | "eslint": "eslint", 21 | "prettier": "prettier" 22 | }, 23 | "engines": { 24 | "node": ">=16.0.0" 25 | }, 26 | "devDependencies": { 27 | "chalk": "^4.1.2", 28 | "eslint": "^8.37.0", 29 | "eslint-config-prettier": "^8.8.0", 30 | "eslint-plugin-n": "^15.7.0", 31 | "eslint-plugin-prettier": "^4.2.1", 32 | "merge-options": "^3.0.4", 33 | "mocha": "^9.2.1", 34 | "nodemon": "^2.0.15", 35 | "prettier": "^2.8.7", 36 | "vite": "^4.3.9" 37 | }, 38 | "dependencies": { 39 | "@opentelemetry/api": "^1.4.1", 40 | "@opentelemetry/auto-instrumentations-node": "^0.36.6", 41 | "@opentelemetry/context-async-hooks": "^1.12.0", 42 | "@opentelemetry/core": "^1.12.0", 43 | "@opentelemetry/exporter-jaeger": "^1.12.0", 44 | "@opentelemetry/instrumentation": "^0.38.0", 45 | "@opentelemetry/propagator-ot-trace": "^0.26.2", 46 | "@opentelemetry/resources": "^1.12.0", 47 | "@opentelemetry/sdk-trace-base": "^1.12.0", 48 | "@opentelemetry/semantic-conventions": "^1.12.0", 49 | "cors": "^2.8.5", 50 | "dompurify": "^2.3.9", 51 | "escape-string-regexp": "^4.0.0", 52 | "express": "^4.17.2", 53 | "hydrogen-view-sdk": "npm:@mlm/hydrogen-view-sdk@^0.28.0-scratch", 54 | "json5": "^2.2.1", 55 | "linkedom": "^0.14.17", 56 | "matrix-viewer-shared": "file:./shared/", 57 | "nconf": "^0.11.3", 58 | "opentelemetry-instrumentation-fetch-node": "^1.0.0", 59 | "url-join": "^4.0.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /server/child-process-runner/child-fork-script.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Called by `child_process` `fork` in `run-in-child-process.js` so we can 4 | // get the data and exit the process cleanly. 5 | 6 | const assert = require('assert'); 7 | 8 | const RethrownError = require('../lib/errors/rethrown-error'); 9 | 10 | // Serialize the error and send it back up to the parent process so we can 11 | // interact with it and know what happened when the process exits. 12 | async function serializeError(err) { 13 | await new Promise((resolve) => { 14 | process.send( 15 | { 16 | error: true, 17 | name: err.name, 18 | message: err.message, 19 | stack: err.stack, 20 | }, 21 | (sendErr) => { 22 | if (sendErr) { 23 | // We just log here instead of rejecting because it's more important 24 | // to see the original error we are trying to send up. Let's just 25 | // throw the original error below. 26 | const sendErrWithDescription = new RethrownError( 27 | 'Failed to send error to the parent process', 28 | sendErr 29 | ); 30 | console.error(sendErrWithDescription); 31 | // This will end up hitting the `unhandledRejection` handler and 32 | // serializing this error instead (worth a shot) 🤷‍♀️ 33 | throw sendErrWithDescription; 34 | } 35 | 36 | resolve(); 37 | } 38 | ); 39 | }); 40 | } 41 | 42 | // We don't exit the process after encountering one of these because maybe it 43 | // doesn't matter to the main-line process in the module. 44 | // 45 | // If we don't listen for these events, the child will exit with status code 1 46 | // (error) when they occur. 47 | process.on('uncaughtException', async (err /*, origin*/) => { 48 | await serializeError(new RethrownError('uncaughtException in child process', err)); 49 | }); 50 | 51 | process.on('unhandledRejection', async (reason /*, promise*/) => { 52 | await serializeError(new RethrownError('unhandledRejection in child process', reason)); 53 | }); 54 | 55 | // Only kick everything off once we receive the options. We pass in the options 56 | // this way instead of argv because we will run into `Error: spawn E2BIG` and 57 | // `Error: spawn ENAMETOOLONG` with argv. 58 | process.on('message', async (runArguments) => { 59 | try { 60 | assert(runArguments); 61 | 62 | // Require the module that we're supposed to run 63 | const modulePath = process.argv[2]; 64 | assert( 65 | modulePath, 66 | 'Expected `modulePath` to be passed into `child-fork-script.js` via argv[2]' 67 | ); 68 | const moduleToRun = require(modulePath); 69 | 70 | // Run the module 71 | const result = await moduleToRun(runArguments); 72 | 73 | assert(result, `No result returned from module we ran (${modulePath}).`); 74 | 75 | // Send back the data we need to the parent. 76 | await new Promise((resolve, reject) => { 77 | process.send( 78 | { 79 | data: result, 80 | }, 81 | (err) => { 82 | if (err) { 83 | return reject(err); 84 | } 85 | 86 | // Exit once we know the data was sent out. We can't gurantee the 87 | // message was received but this should work pretty well. 88 | // 89 | // Related: 90 | // - https://stackoverflow.com/questions/34627546/process-send-is-sync-async-on-nix-windows 91 | // - https://github.com/nodejs/node/commit/56d9584a0ead78874ca9d4de2e55b41c4056e502 92 | // - https://github.com/nodejs/node/issues/6767 93 | process.exit(0); 94 | resolve(); 95 | } 96 | ); 97 | }); 98 | } catch (err) { 99 | // We need to wait for the error to completely send to the parent 100 | // process before we exit the process. 101 | await serializeError(err); 102 | 103 | // Fail the process and exit 104 | process.exit(1); 105 | } 106 | }); 107 | -------------------------------------------------------------------------------- /server/hydrogen-render/render-hydrogen-to-string-unsafe.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Server-side render Hydrogen to a string using a browser-like context thanks 4 | // to `linkedom`. We use a VM so we can put all of the browser-like globals in 5 | // place. 6 | // 7 | // Note: This is marked as unsafe because the render script is run in a VM which 8 | // doesn't exit after we get the result (Hydrogen keeps running). There isn't a 9 | // way to stop, terminate, or kill a vm script or vm context so in order to be 10 | // safe, we need to run this inside of a child_process which we can kill after. 11 | // This is why we have the `1-render-hydrogen-to-string.js` layer to handle 12 | // this. 13 | 14 | const assert = require('assert'); 15 | const vm = require('vm'); 16 | const path = require('path'); 17 | const { readFile } = require('fs').promises; 18 | const crypto = require('crypto'); 19 | const { parseHTML } = require('linkedom'); 20 | 21 | // Setup the DOM context with any necessary shims/polyfills and ensure the VM 22 | // context global has everything that a normal document does so Hydrogen can 23 | // render. 24 | function createDomAndSetupVmContext() { 25 | const dom = parseHTML(` 26 | 27 | 28 | 29 | 30 |
31 | 32 | 33 | `); 34 | 35 | if (!dom.requestAnimationFrame) { 36 | dom.requestAnimationFrame = function (cb) { 37 | setTimeout(cb, 0); 38 | }; 39 | } 40 | 41 | const vmContext = vm.createContext(dom); 42 | // Make the dom properties available in sub-`require(...)` calls 43 | vmContext.global.window = dom.window; 44 | vmContext.global.document = dom.document; 45 | vmContext.global.Node = dom.Node; 46 | vmContext.global.navigator = dom.navigator; 47 | vmContext.global.DOMParser = dom.DOMParser; 48 | // Make sure `webcrypto` exists since it was only introduced in Node.js v17 49 | assert(crypto.webcrypto); 50 | // Only assign vmContext.global.crypto if it's undefined 51 | // (Node.js v19 has crypto set on the global already) 52 | if (!vmContext.global.crypto) { 53 | vmContext.global.crypto = crypto.webcrypto; 54 | } 55 | 56 | // So require(...) works in the vm 57 | vmContext.global.require = require; 58 | // So we can see logs from the underlying vm 59 | vmContext.global.console = console; 60 | 61 | return { 62 | dom, 63 | vmContext, 64 | }; 65 | } 66 | 67 | async function _renderHydrogenToStringUnsafe(renderOptions) { 68 | assert(renderOptions); 69 | assert(renderOptions.vmRenderScriptFilePath); 70 | assert(renderOptions.vmRenderContext); 71 | assert(renderOptions.pageOptions); 72 | assert(renderOptions.pageOptions.locationUrl); 73 | assert(renderOptions.pageOptions.cspNonce); 74 | 75 | const { dom, vmContext } = createDomAndSetupVmContext(); 76 | 77 | // A small `window.location` stub 78 | if (!dom.window.location) { 79 | const locationUrl = new URL(renderOptions.pageOptions.locationUrl); 80 | dom.window.location = {}; 81 | [ 82 | 'hash', 83 | 'host', 84 | 'hostname', 85 | 'href', 86 | 'origin', 87 | 'password', 88 | 'pathname', 89 | 'port', 90 | 'protocol', 91 | 'search', 92 | 'username', 93 | ].forEach((key) => { 94 | dom.window.location[key] = locationUrl[key]; 95 | }); 96 | } 97 | 98 | // Define this for the SSR context 99 | dom.window.matrixViewerContext = { 100 | ...renderOptions.vmRenderContext, 101 | }; 102 | 103 | const vmRenderScriptFilePath = renderOptions.vmRenderScriptFilePath; 104 | const hydrogenRenderScriptCode = await readFile(vmRenderScriptFilePath, 'utf8'); 105 | const hydrogenRenderScript = new vm.Script(hydrogenRenderScriptCode, { 106 | filename: path.basename(vmRenderScriptFilePath), 107 | }); 108 | // Note: The VM does not exit after the result is returned here and is why 109 | // this should be run in a `child_process` that we can exit. 110 | const vmResult = hydrogenRenderScript.runInContext(vmContext); 111 | // Wait for everything to render 112 | // (waiting on the promise returned from the VM render script) 113 | await vmResult; 114 | 115 | const documentString = dom.document.body.toString(); 116 | assert(documentString, 'Document body should not be empty after we rendered Hydrogen'); 117 | return documentString; 118 | } 119 | 120 | module.exports = _renderHydrogenToStringUnsafe; 121 | -------------------------------------------------------------------------------- /server/hydrogen-render/render-hydrogen-to-string.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Server-side render Hydrogen to a string. 4 | // 5 | // We use a `child_process` because we want to be able to exit the process after 6 | // we receive the SSR results. We don't want Hydrogen to keep running after we 7 | // get our initial rendered HTML. 8 | 9 | const assert = require('assert'); 10 | const RethrownError = require('../lib/errors/rethrown-error'); 11 | const RouteTimeoutAbortError = require('../lib/errors/route-timeout-abort-error'); 12 | const UserClosedConnectionAbortError = require('../lib/errors/user-closed-connection-abort-error'); 13 | const runInChildProcess = require('../child-process-runner/run-in-child-process'); 14 | 15 | const resolvedRenderHydrogenToStringUnsafeScriptPath = require.resolve( 16 | './render-hydrogen-to-string-unsafe' 17 | ); 18 | 19 | // The render should be fast. If it's taking more than 5 seconds, something has 20 | // gone really wrong. 21 | const RENDER_TIMEOUT = 5000; 22 | 23 | async function renderHydrogenToString({ renderOptions, abortSignal }) { 24 | assert(renderOptions); 25 | 26 | // We expect `config` but we should sanity check that we aren't leaking the access token 27 | // to the client if someone naievely copied the whole `config` object to here. 28 | assert(renderOptions.vmRenderContext.config); 29 | assert( 30 | !renderOptions.vmRenderContext.config.matrixAccessToken, 31 | 'We should not be leaking the `config.matrixAccessToken` to the Hydrogen render function because this will reach the client!' 32 | ); 33 | 34 | try { 35 | // In development, if you're running into a hard to track down error with 36 | // the render hydrogen stack and fighting against the multiple layers of 37 | // complexity with `child_process `and `vm`; you can get away with removing 38 | // the `child_process` part of it by using 39 | // `render-hydrogen-to-string-unsafe` directly. 40 | // ```js 41 | // const _renderHydrogenToStringUnsafe = require('../hydrogen-render/render-hydrogen-to-string-unsafe'); 42 | // const hydrogenHtmlOutput = await _renderHydrogenToStringUnsafe(renderOptions); 43 | // ``` 44 | // 45 | // We use a child_process because we want to be able to exit the process after 46 | // we receive the SSR results. We don't want Hydrogen to keep running after we 47 | // get our initial rendered HTML. 48 | const hydrogenHtmlOutput = await runInChildProcess( 49 | resolvedRenderHydrogenToStringUnsafeScriptPath, 50 | renderOptions, 51 | { 52 | timeout: RENDER_TIMEOUT, 53 | abortSignal, 54 | } 55 | ); 56 | 57 | return hydrogenHtmlOutput; 58 | } catch (err) { 59 | // No need to wrap these errors since the abort originates from outside of the 60 | // render process. And makes it easier to detect without having to look for 61 | // underlying causes. 62 | if (err instanceof RouteTimeoutAbortError || err instanceof UserClosedConnectionAbortError) { 63 | throw err; 64 | } else { 65 | throw new RethrownError( 66 | `Failed to render Hydrogen to string. In order to reproduce, feed in these arguments into \`renderHydrogenToString(...)\`:\n renderHydrogenToString arguments: ${JSON.stringify( 67 | renderOptions 68 | )}`, 69 | err 70 | ); 71 | } 72 | } 73 | } 74 | 75 | module.exports = renderHydrogenToString; 76 | -------------------------------------------------------------------------------- /server/hydrogen-render/render-hydrogen-vm-render-script-to-page-html.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | 5 | const renderHydrogenToString = require('../hydrogen-render/render-hydrogen-to-string'); 6 | const renderPageHtml = require('../hydrogen-render/render-page-html'); 7 | 8 | async function renderHydrogenVmRenderScriptToPageHtml({ 9 | pageOptions, 10 | vmRenderScriptFilePath, 11 | vmRenderContext, 12 | abortSignal, 13 | }) { 14 | assert(vmRenderScriptFilePath); 15 | assert(vmRenderContext); 16 | assert(pageOptions); 17 | 18 | const hydrogenHtmlOutput = await renderHydrogenToString({ 19 | renderOptions: { 20 | vmRenderScriptFilePath, 21 | vmRenderContext, 22 | pageOptions, 23 | }, 24 | abortSignal, 25 | }); 26 | 27 | const pageHtml = renderPageHtml({ 28 | pageOptions, 29 | bodyHtml: hydrogenHtmlOutput, 30 | vmRenderContext, 31 | }); 32 | 33 | return pageHtml; 34 | } 35 | 36 | module.exports = renderHydrogenVmRenderScriptToPageHtml; 37 | -------------------------------------------------------------------------------- /server/hydrogen-render/render-page-html.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const urlJoin = require('url-join'); 5 | 6 | const { getSerializableSpans } = require('../tracing/tracing-middleware'); 7 | const sanitizeHtml = require('../lib/sanitize-html'); 8 | const safeJson = require('../lib/safe-json'); 9 | const getDependenciesForEntryPointName = require('../lib/get-dependencies-for-entry-point-name'); 10 | const getAssetUrl = require('../lib/get-asset-url'); 11 | 12 | const config = require('../lib/config'); 13 | const basePath = config.get('basePath'); 14 | assert(basePath); 15 | 16 | let _assetUrls; 17 | function getAssetUrls() { 18 | // Probably not that much overhead but only calculate this once 19 | if (_assetUrls) { 20 | return _assetUrls; 21 | } 22 | 23 | _assetUrls = { 24 | faviconIco: getAssetUrl('client/img/favicon.ico'), 25 | faviconSvg: getAssetUrl('client/img/favicon.svg'), 26 | opengraphImage: getAssetUrl('client/img/opengraph.png'), 27 | }; 28 | return _assetUrls; 29 | } 30 | 31 | function renderPageHtml({ 32 | pageOptions, 33 | // Make sure you sanitize this before passing it to us 34 | bodyHtml, 35 | vmRenderContext, 36 | }) { 37 | assert(vmRenderContext); 38 | assert(pageOptions); 39 | assert(pageOptions.title); 40 | assert(pageOptions.description); 41 | assert(pageOptions.entryPoint); 42 | assert(pageOptions.cspNonce); 43 | 44 | const { styles, scripts } = getDependenciesForEntryPointName(pageOptions.entryPoint); 45 | 46 | // Serialize the state for when we run the Hydrogen render again client-side to 47 | // re-hydrate the DOM 48 | const serializedMatrixViewerContext = JSON.stringify({ 49 | ...vmRenderContext, 50 | }); 51 | 52 | const serializableSpans = getSerializableSpans(); 53 | const serializedSpans = JSON.stringify(serializableSpans); 54 | 55 | // We shouldn't let some pages be indexed by search engines 56 | let maybeNoIndexHtml = ''; 57 | if (!pageOptions.shouldIndex) { 58 | maybeNoIndexHtml = ``; 59 | } 60 | 61 | // We should tell search engines that some pages are NSFW, see 62 | // https://developers.google.com/search/docs/crawling-indexing/safesearch 63 | let maybeAdultMeta = ''; 64 | if (pageOptions.blockedBySafeSearch) { 65 | maybeAdultMeta = ``; 66 | } 67 | 68 | const pageAssetUrls = getAssetUrls(); 69 | let metaImageUrl = urlJoin(basePath, pageAssetUrls.opengraphImage); 70 | if (pageOptions.imageUrl) { 71 | metaImageUrl = pageOptions.imageUrl; 72 | } 73 | 74 | let maybeRelCanonical = ''; 75 | if (pageOptions.canonicalUrl) { 76 | maybeRelCanonical = sanitizeHtml(``); 77 | } 78 | 79 | const pageHtml = ` 80 | 81 | 82 | 83 | 84 | ${maybeNoIndexHtml} 85 | ${maybeAdultMeta} 86 | ${sanitizeHtml(`${pageOptions.title}`)} 87 | ${sanitizeHtml(``)} 88 | ${sanitizeHtml(``)} 89 | 90 | 91 | ${maybeRelCanonical} 92 | ${styles 93 | .map( 94 | (styleUrl) => 95 | `` 96 | ) 97 | .join('\n')} 98 | 99 | 100 | ${bodyHtml} 101 | 102 | ${ 103 | /** 104 | * This inline snippet is used in to scroll the Hydrogen timeline to the 105 | * right place immediately when the page loads instead of waiting for 106 | * Hydrogen to load, hydrate and finally scroll. 107 | */ '' 108 | } 109 | 124 | 125 | 128 | 129 | ${scripts 130 | .map( 131 | (scriptUrl) => 132 | `` 133 | ) 134 | .join('\n')} 135 | 138 | 139 | 140 | `; 141 | 142 | return pageHtml; 143 | } 144 | 145 | module.exports = renderPageHtml; 146 | -------------------------------------------------------------------------------- /server/lib/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This file is based off the Gitter config, 4 | // https://gitlab.com/gitlab-org/gitter/env/blob/master/lib/config.js 5 | 6 | const path = require('path'); 7 | const nconf = require('nconf'); 8 | const JSON5 = require('json5'); 9 | 10 | function configureNodeEnv() { 11 | const nodeEnv = process.env.NODE_ENV; 12 | if (nodeEnv === 'production') { 13 | return 'prod'; 14 | } else if (nodeEnv === 'development') { 15 | return 'dev'; 16 | } 17 | if (nodeEnv) return nodeEnv; 18 | 19 | // Default to NODE_ENV=dev 20 | process.env.NODE_ENV = 'dev'; 21 | return 'dev'; 22 | } 23 | 24 | const nodeEnv = configureNodeEnv(); 25 | console.log(`Config is using nodeEnv=${nodeEnv}`); 26 | const configDir = path.join(__dirname, '../../config'); 27 | 28 | // Setup nconf to use (in-order): 29 | // 1. Command-line arguments (argv) 30 | // 2. Environment variables (env) 31 | // 3. `config/config-{env}.json` files 32 | nconf.argv().env('__'); 33 | 34 | nconf.add('envUser', { 35 | type: 'file', 36 | file: path.join(configDir, 'config.' + nodeEnv + '.user-overrides.json'), 37 | format: JSON5, 38 | }); 39 | 40 | // Only use user-overrides in dev 41 | if (nodeEnv === 'dev') { 42 | nconf.add('user', { 43 | type: 'file', 44 | file: path.join(configDir, 'config.user-overrides.json'), 45 | format: JSON5, 46 | }); 47 | } 48 | 49 | nconf.add('nodeEnv', { 50 | type: 'file', 51 | file: path.join(configDir, 'config.' + nodeEnv + '.json'), 52 | format: JSON5, 53 | }); 54 | nconf.add('defaults', { 55 | type: 'file', 56 | file: path.join(configDir, 'config.default.json'), 57 | format: JSON5, 58 | }); 59 | 60 | module.exports = nconf; 61 | -------------------------------------------------------------------------------- /server/lib/errors/extended-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Standard error extender from @deployable/errors 4 | // (https://github.com/deployable/deployable-errors) 5 | class ExtendedError extends Error { 6 | constructor(message) { 7 | super(message); 8 | this.name = this.constructor.name; 9 | this.message = message; 10 | if (typeof Error.captureStackTrace === 'function') { 11 | Error.captureStackTrace(this, this.constructor); 12 | } else { 13 | this.stack = new Error(message).stack; 14 | } 15 | } 16 | } 17 | 18 | module.exports = ExtendedError; 19 | -------------------------------------------------------------------------------- /server/lib/errors/rethrown-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ExtendedError = require('./extended-error'); 4 | 5 | // A way to create a new error with a custom message but keep the stack trace of 6 | // the original error. Useful to give more context and why the action was tried 7 | // in the first place. 8 | // 9 | // For example, if you get a generic EACCES disk error of a certain file, you 10 | // want to know why and what context the disk was trying to be read. A 11 | // stack-trace is not human digestable and only gives the where in the code. 12 | // What I actually need to know is that I was trying to read the `ratelimit` key 13 | // from the config when this error occured. 14 | // 15 | // `new RethrownError('Failed to get the ratelimit key from the config', originalError)` (failed to read the disk) 16 | // 17 | // via https://stackoverflow.com/a/42755876/796832 18 | class RethrownError extends ExtendedError { 19 | constructor(message, error) { 20 | super(message); 21 | if (!error) throw new Error('RethrownError requires a message and error'); 22 | this.original = error; 23 | this.originalStack = this.stack; 24 | 25 | // The number of lines that make up the message itself. We count this by the 26 | // number of `\n` and `+ 1` for the first line because it doesn't start with 27 | // new line. 28 | const messageLines = (this.message.match(/\n/g) || []).length + 1; 29 | 30 | const indentedOriginalError = error.stack 31 | .split(/\r?\n/) 32 | .map((line) => ` ${line}`) 33 | .join('\n'); 34 | 35 | this.stack = 36 | this.stack 37 | .split('\n') 38 | // We use `+ 1` here so that we include the first line of the stack to 39 | // people know where the error was thrown from. 40 | .slice(0, messageLines + 1) 41 | .join('\n') + 42 | '\n' + 43 | ' --- Original Error ---\n' + 44 | indentedOriginalError; 45 | } 46 | } 47 | 48 | module.exports = RethrownError; 49 | -------------------------------------------------------------------------------- /server/lib/errors/route-timeout-abort-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ExtendedError = require('./extended-error'); 4 | 5 | class RouteTimeoutAbortError extends ExtendedError { 6 | // ... 7 | } 8 | 9 | module.exports = RouteTimeoutAbortError; 10 | -------------------------------------------------------------------------------- /server/lib/errors/status-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var http = require('http'); 4 | 5 | /* Create an error as per http://bluebirdjs.com/docs/api/catch.html */ 6 | function StatusError(status, inputMessage) { 7 | let message = inputMessage; 8 | if (!inputMessage) { 9 | message = http.STATUS_CODES[status] || http.STATUS_CODES['500']; 10 | } 11 | 12 | this.message = `${status} - ${message}`; 13 | // This will be picked by the default Express error handler and assign the status code, 14 | // https://expressjs.com/en/guide/error-handling.html#the-default-error-handler 15 | this.status = status; 16 | this.name = 'StatusError'; 17 | Error.captureStackTrace(this, StatusError); 18 | } 19 | StatusError.prototype = Object.create(Error.prototype); 20 | StatusError.prototype.constructor = StatusError; 21 | 22 | module.exports = StatusError; 23 | -------------------------------------------------------------------------------- /server/lib/errors/user-closed-connection-abort-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ExtendedError = require('./extended-error'); 4 | 5 | class UserClosedConnectionAbortError extends ExtendedError { 6 | // ... 7 | } 8 | 9 | module.exports = UserClosedConnectionAbortError; 10 | -------------------------------------------------------------------------------- /server/lib/express-async-handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Simple middleware for handling exceptions inside of async express routes and 4 | // passing them to your express error handlers. 5 | // 6 | // via https://github.com/Abazhenov/express-async-handler 7 | const asyncUtil = (fn) => 8 | function asyncUtilWrap(...args) { 9 | const fnReturn = fn(...args); 10 | const next = args[args.length - 1]; 11 | return Promise.resolve(fnReturn).catch(next); 12 | }; 13 | 14 | module.exports = asyncUtil; 15 | -------------------------------------------------------------------------------- /server/lib/fetch-endpoint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | 5 | class HTTPResponseError extends Error { 6 | constructor(response, responseText, ...args) { 7 | super( 8 | `HTTP Error Response: ${response.status} ${response.statusText}: ${responseText}\n URL=${response.url}`, 9 | ...args 10 | ); 11 | this.response = response; 12 | } 13 | } 14 | 15 | const checkResponseStatus = async (response) => { 16 | if (response.ok) { 17 | // response.status >= 200 && response.status < 300 18 | return response; 19 | } else { 20 | const responseText = await response.text(); 21 | throw new HTTPResponseError(response, responseText); 22 | } 23 | }; 24 | 25 | async function fetchEndpoint(endpoint, options = {}) { 26 | // We chose `abortSignal` just because it's a less ambiguous name and obvious what 27 | // it's used for. 28 | assert(!options.signal, 'Use `options.abortSignal` instead of `options.signal`'); 29 | 30 | const { method, accessToken } = options; 31 | const headers = options.headers || {}; 32 | 33 | if (accessToken) { 34 | headers.Authorization = `Bearer ${accessToken}`; 35 | } 36 | 37 | const res = await fetch(endpoint, { 38 | method, 39 | headers, 40 | body: options.body, 41 | // Abort signal to cancel the request 42 | signal: options.abortSignal, 43 | }); 44 | await checkResponseStatus(res); 45 | 46 | return res; 47 | } 48 | 49 | async function fetchEndpointAsText(endpoint, options) { 50 | const res = await fetchEndpoint(endpoint, options); 51 | const data = await res.text(); 52 | return { data, res }; 53 | } 54 | 55 | async function fetchEndpointAsJson(endpoint, options) { 56 | const opts = { 57 | ...(options || {}), 58 | headers: { 59 | Accept: 'application/json', 60 | 'Content-Type': 'application/json', 61 | ...(options?.headers || {}), 62 | }, 63 | }; 64 | 65 | if (options?.body) { 66 | opts.body = JSON.stringify(options.body); 67 | } 68 | 69 | const res = await fetchEndpoint(endpoint, opts); 70 | const data = await res.json(); 71 | return { data, res }; 72 | } 73 | 74 | module.exports = { 75 | HTTPResponseError, 76 | fetchEndpoint, 77 | fetchEndpointAsText, 78 | fetchEndpointAsJson, 79 | }; 80 | -------------------------------------------------------------------------------- /server/lib/get-asset-url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path').posix; 4 | 5 | function getAssetUrl(inputAssetPath) { 6 | // Lazy-load the manifest so we only require it on first call hopefully after the Vite 7 | // client build completes. `require(...)` calls are cached so it should be fine to 8 | // look this up over and over. 9 | // 10 | // We have to disable the `no-missing-require` because the file is built via the Vite client build. 11 | // eslint-disable-next-line n/no-missing-require, n/no-unpublished-require 12 | const manfiest = require('../../dist/manifest.json'); 13 | 14 | const assetEntry = manfiest[inputAssetPath]; 15 | if (!assetEntry) { 16 | throw new Error(`Could not find asset with path "${inputAssetPath}" in \`dist/manifest.json\``); 17 | } 18 | 19 | const outputAssetPath = path.join('/', assetEntry.file); 20 | 21 | return outputAssetPath; 22 | } 23 | 24 | module.exports = getAssetUrl; 25 | -------------------------------------------------------------------------------- /server/lib/get-version-tags.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | const packageInfo = require('../../package.json'); 8 | assert(packageInfo.version); 9 | 10 | const packageVersion = packageInfo.version; 11 | 12 | function readVersionFileSync(path) { 13 | try { 14 | return fs.readFileSync(path, 'utf8').trim(); 15 | } catch (err) { 16 | console.warn(`Unable to read version tags path=${path}`, err); 17 | return null; 18 | } 19 | } 20 | 21 | const commit = readVersionFileSync(path.join(__dirname, '../../dist/GIT_COMMIT'), 'utf8'); 22 | const version = readVersionFileSync(path.join(__dirname, '../../dist/VERSION'), 'utf8'); 23 | const versionDate = readVersionFileSync(path.join(__dirname, '../../dist/VERSION_DATE'), 'utf8'); 24 | 25 | function getVersionTags() { 26 | return { 27 | commit, 28 | version, 29 | versionDate, 30 | packageVersion, 31 | }; 32 | } 33 | 34 | module.exports = getVersionTags; 35 | -------------------------------------------------------------------------------- /server/lib/matrix-utils/ensure-room-joined.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const urlJoin = require('url-join'); 5 | 6 | const StatusError = require('../errors/status-error'); 7 | const { fetchEndpointAsJson } = require('../fetch-endpoint'); 8 | const getServerNameFromMatrixRoomIdOrAlias = require('./get-server-name-from-matrix-room-id-or-alias'); 9 | const MatrixViewerURLCreator = require('matrix-viewer-shared/lib/url-creator'); 10 | 11 | const config = require('../config'); 12 | const basePath = config.get('basePath'); 13 | assert(basePath); 14 | const matrixServerUrl = config.get('matrixServerUrl'); 15 | assert(matrixServerUrl); 16 | 17 | const matrixViewerURLCreator = new MatrixViewerURLCreator(basePath); 18 | 19 | async function ensureRoomJoined( 20 | accessToken, 21 | roomIdOrAlias, 22 | { viaServers = new Set(), abortSignal } = {} 23 | ) { 24 | // We use a `Set` to ensure that we don't have duplicate servers in the list 25 | assert(viaServers instanceof Set); 26 | 27 | // Let's do our best for the user to join the room. Since room ID's are 28 | // unroutable on their own and won't be found if the server doesn't already 29 | // know about the room, we'll try to join the room via the server name that 30 | // we derived from the room ID or alias. 31 | const viaServersWithAssumptions = new Set(viaServers); 32 | const derivedServerName = getServerNameFromMatrixRoomIdOrAlias(roomIdOrAlias); 33 | if (derivedServerName) { 34 | viaServersWithAssumptions.add(derivedServerName); 35 | } 36 | 37 | let qs = new URLSearchParams(); 38 | Array.from(viaServersWithAssumptions).forEach((viaServer) => { 39 | qs.append('server_name', viaServer); 40 | }); 41 | 42 | const joinEndpoint = urlJoin( 43 | matrixServerUrl, 44 | `_matrix/client/r0/join/${encodeURIComponent(roomIdOrAlias)}?${qs.toString()}` 45 | ); 46 | try { 47 | const { data: joinData } = await fetchEndpointAsJson(joinEndpoint, { 48 | method: 'POST', 49 | accessToken, 50 | abortSignal, 51 | body: { 52 | reason: 53 | `Joining room to check history visibility. ` + 54 | `If your room is public with shared or world readable history visibility, ` + 55 | `it will be accessible on ${matrixViewerURLCreator.roomDirectoryUrl()}. ` + 56 | `See the FAQ for more details: ` + 57 | `https://github.com/matrix-org/matrix-viewer/blob/main/docs/faq.md#why-did-the-bot-join-my-room`, 58 | }, 59 | }); 60 | assert( 61 | joinData.room_id, 62 | `Join endpoint (${joinEndpoint}) did not return \`room_id\` as expected. This is probably a problem with that homeserver.` 63 | ); 64 | return joinData.room_id; 65 | } catch (err) { 66 | throw new StatusError(403, `Bot is unable to join room: ${err.message}`); 67 | } 68 | } 69 | 70 | module.exports = ensureRoomJoined; 71 | -------------------------------------------------------------------------------- /server/lib/matrix-utils/fetch-events-from-timestamp-backwards.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const { traceFunction } = require('../../tracing/trace-utilities'); 5 | 6 | const { DIRECTION } = require('matrix-viewer-shared/lib/reference-values'); 7 | const timestampToEvent = require('./timestamp-to-event'); 8 | const getMessagesResponseFromEventId = require('./get-messages-response-from-event-id'); 9 | 10 | const config = require('../config'); 11 | const matrixServerUrl = config.get('matrixServerUrl'); 12 | assert(matrixServerUrl); 13 | 14 | // Find an event right ahead of where we are trying to look. Then paginate 15 | // /messages backwards. This makes sure that we can get events for the day when 16 | // the room started. And it ensures that the `/messages` backfill kicks in 17 | // properly since it only works to fill in the gaps going backwards. 18 | // 19 | // Consider this scenario: dayStart(fromTs) <- msg1 <- msg2 <- msg3 <- dayEnd(toTs) 20 | // - ❌ If we start from dayStart and look backwards, we will find nothing. 21 | // - ❌ If we start from dayStart and look forwards, we will find msg1, but 22 | // federated backfill won't be able to paginate forwards 23 | // - ✅ If we start from dayEnd and look backwards, we will find msg3 and 24 | // federation backfill can paginate backwards 25 | // - ❌ If we start from dayEnd and look forwards, we will find nothing 26 | // 27 | // Returns events in reverse-chronological order. 28 | async function fetchEventsFromTimestampBackwards({ accessToken, roomId, ts, limit, abortSignal }) { 29 | assert(accessToken); 30 | assert(roomId); 31 | assert(ts); 32 | // Synapse has a max `/messages` limit of 1000 33 | assert( 34 | limit <= 1000, 35 | 'We can only get 1000 messages at a time from Synapse. If you need more messages, we will have to implement pagination' 36 | ); 37 | 38 | let eventIdForTimestamp; 39 | try { 40 | const { eventId } = await timestampToEvent({ 41 | accessToken, 42 | roomId, 43 | ts, 44 | direction: DIRECTION.backward, 45 | abortSignal, 46 | }); 47 | eventIdForTimestamp = eventId; 48 | } catch (err) { 49 | const allowedErrorCodes = [ 50 | // Allow `404: Unable to find event xxx in direction x` 51 | // so we can just display an empty placeholder with no events. 52 | 404, 53 | ]; 54 | if (!allowedErrorCodes.includes(err?.response?.status)) { 55 | throw err; 56 | } 57 | } 58 | 59 | if (!eventIdForTimestamp) { 60 | return { 61 | stateEventMap: {}, 62 | events: [], 63 | }; 64 | } 65 | 66 | const messageResData = await getMessagesResponseFromEventId({ 67 | accessToken, 68 | roomId, 69 | eventId: eventIdForTimestamp, 70 | // We go backwards because that's the direction that backfills events (Synapse 71 | // doesn't backfill in the forward direction) 72 | dir: DIRECTION.backward, 73 | limit, 74 | abortSignal, 75 | }); 76 | 77 | const stateEventMap = {}; 78 | for (const stateEvent of messageResData.state || []) { 79 | if (stateEvent.type === 'm.room.member') { 80 | stateEventMap[stateEvent.state_key] = stateEvent; 81 | } 82 | } 83 | 84 | const chronologicalEvents = messageResData?.chunk?.reverse() || []; 85 | 86 | return { 87 | stateEventMap, 88 | events: chronologicalEvents, 89 | }; 90 | } 91 | 92 | module.exports = traceFunction(fetchEventsFromTimestampBackwards); 93 | -------------------------------------------------------------------------------- /server/lib/matrix-utils/get-messages-response-from-event-id.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const urlJoin = require('url-join'); 5 | 6 | const { DIRECTION } = require('matrix-viewer-shared/lib/reference-values'); 7 | const { fetchEndpointAsJson } = require('../fetch-endpoint'); 8 | 9 | const config = require('../config'); 10 | const matrixServerUrl = config.get('matrixServerUrl'); 11 | assert(matrixServerUrl); 12 | 13 | async function getMessagesResponseFromEventId({ 14 | accessToken, 15 | roomId, 16 | eventId, 17 | dir, 18 | limit, 19 | abortSignal, 20 | }) { 21 | // We only use this endpoint to get a pagination token we can use with 22 | // `/messages`. 23 | // 24 | // We add `limit=0` here because we want to grab the pagination token right 25 | // (before/after) the event. 26 | // 27 | // Add `filter={"lazy_load_members":true}` so that this endpoint responds 28 | // without timing out by returning just the state for the sender of the 29 | // included event. Otherwise, the homeserver returns all state in the room at 30 | // that point in time which in big rooms, can be 100k member events that we 31 | // don't care about anyway. Synapse seems to timeout at about the ~5k state 32 | // event mark. 33 | const contextEndpoint = urlJoin( 34 | matrixServerUrl, 35 | `_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/context/${encodeURIComponent( 36 | eventId 37 | )}?limit=0&filter={"lazy_load_members":true}` 38 | ); 39 | const { data: contextResData } = await fetchEndpointAsJson(contextEndpoint, { 40 | accessToken, 41 | abortSignal, 42 | }); 43 | 44 | // We want to re-paginte over the same event so it's included in the response. 45 | // 46 | // When going backwards, that means starting using the paginatin token after the event 47 | // so we can see it looking backwards again. 48 | let paginationToken = contextResData.end; 49 | // When going forwards, that means starting using the paginatin token before the event 50 | // so we can see it looking forwards again. 51 | if (dir === DIRECTION.forward) { 52 | paginationToken = contextResData.start; 53 | } 54 | 55 | // Add `filter={"lazy_load_members":true}` to only get member state events for 56 | // the messages included in the response 57 | const messagesEndpoint = urlJoin( 58 | matrixServerUrl, 59 | `_matrix/client/r0/rooms/${encodeURIComponent( 60 | roomId 61 | )}/messages?dir=${dir}&from=${encodeURIComponent( 62 | paginationToken 63 | )}&limit=${limit}&filter={"lazy_load_members":true}` 64 | ); 65 | const { data: messageResData } = await fetchEndpointAsJson(messagesEndpoint, { 66 | accessToken, 67 | abortSignal, 68 | }); 69 | 70 | return messageResData; 71 | } 72 | 73 | module.exports = getMessagesResponseFromEventId; 74 | -------------------------------------------------------------------------------- /server/lib/matrix-utils/get-server-name-from-matrix-room-id-or-alias.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | 5 | // See https://spec.matrix.org/v1.5/appendices/#server-name 6 | function getServerNameFromMatrixRoomIdOrAlias(roomIdOrAlias) { 7 | // `roomIdOrAlias` looks like `!foo:matrix.org` or `#foo:matrix.org` or even something 8 | // as crazy as `!foo:[1234:5678::abcd]:1234` where `[1234:5678::abcd]:1234` is the 9 | // server name part we're trying to parse out (see tests for more examples) 10 | assert(roomIdOrAlias); 11 | 12 | const pieces = roomIdOrAlias.split(':'); 13 | // We can only derive the server name if there is a colon in the string. Since room 14 | // IDs are supposed to be treated as opaque strings, there is a future possibility 15 | // that they will not contain a colon. 16 | if (pieces.length < 2) { 17 | return null; 18 | } 19 | 20 | const servername = pieces.slice(1).join(':'); 21 | 22 | return servername; 23 | } 24 | 25 | module.exports = getServerNameFromMatrixRoomIdOrAlias; 26 | -------------------------------------------------------------------------------- /server/lib/matrix-utils/timestamp-to-event.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const urlJoin = require('url-join'); 5 | 6 | const { fetchEndpointAsJson } = require('../fetch-endpoint'); 7 | const { traceFunction } = require('../../tracing/trace-utilities'); 8 | 9 | const config = require('../config'); 10 | const matrixServerUrl = config.get('matrixServerUrl'); 11 | assert(matrixServerUrl); 12 | 13 | async function timestampToEvent({ accessToken, roomId, ts, direction, abortSignal }) { 14 | assert(accessToken); 15 | assert(roomId); 16 | assert(ts); 17 | assert(direction); 18 | // TODO: Handle `fromCausalEventId` -> `org.matrix.msc3999.event_id`: See MSC3999 19 | // (https://github.com/matrix-org/matrix-spec-proposals/pull/3999) 20 | 21 | const timestampToEventEndpoint = urlJoin( 22 | matrixServerUrl, 23 | `_matrix/client/v1/rooms/${encodeURIComponent( 24 | roomId 25 | )}/timestamp_to_event?ts=${encodeURIComponent(ts)}&dir=${encodeURIComponent(direction)}` 26 | ); 27 | const { data: timestampToEventResData } = await fetchEndpointAsJson(timestampToEventEndpoint, { 28 | accessToken, 29 | abortSignal, 30 | }); 31 | 32 | return { 33 | eventId: timestampToEventResData.event_id, 34 | originServerTs: timestampToEventResData.origin_server_ts, 35 | }; 36 | } 37 | 38 | module.exports = traceFunction(timestampToEvent); 39 | -------------------------------------------------------------------------------- /server/lib/parse-via-servers-from-user-input.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const StatusError = require('./errors/status-error'); 4 | 5 | function parseViaServersFromUserInput(rawViaServers) { 6 | // `rawViaServers` could be an array, a single string, or undefined. Turn it into an 7 | // array no matter what 8 | const rawViaServerList = [].concat(rawViaServers || []); 9 | if (rawViaServerList.length === 0) { 10 | return new Set(); 11 | } 12 | 13 | const viaServerList = rawViaServerList.map((viaServer) => { 14 | // Sanity check to ensure that the via servers are strings (valid enough looking 15 | // host names) 16 | if (typeof viaServer !== 'string') { 17 | throw new StatusError( 18 | 400, 19 | `?via server must be a string, got ${viaServer} (${typeof viaServer})` 20 | ); 21 | } 22 | 23 | return viaServer; 24 | }); 25 | 26 | // We use a `Set` to ensure that we don't have duplicate servers in the list 27 | return new Set(viaServerList); 28 | } 29 | 30 | module.exports = parseViaServersFromUserInput; 31 | -------------------------------------------------------------------------------- /server/lib/safe-json.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // via https://gitlab.com/gitterHQ/webapp/-/blob/615c78d0b0a314c2c9e4098b8d2ba0471d16961b/modules/templates/lib/safe-json.js 4 | function safeJson(string) { 5 | if (!string) return string; 6 | // From http://benalpert.com/2012/08/03/preventing-xss-json.html 7 | return string.replace(/<\//g, '<\\/'); 8 | } 9 | 10 | module.exports = safeJson; 11 | -------------------------------------------------------------------------------- /server/lib/sanitize-html.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const createDOMPurify = require('dompurify'); 4 | const { parseHTML } = require('linkedom'); 5 | 6 | const dom = parseHTML(` 7 | 8 | 9 | 10 | 11 | 12 | `); 13 | 14 | const DOMPurify = createDOMPurify(dom.window); 15 | 16 | function sanitizeHtml(dirtyHtml) { 17 | const cleanHtml = DOMPurify.sanitize(dirtyHtml); 18 | return cleanHtml; 19 | } 20 | 21 | module.exports = sanitizeHtml; 22 | -------------------------------------------------------------------------------- /server/lib/set-headers-for-date-temporal-context.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | 5 | const { getUtcStartOfDayTs } = require('matrix-viewer-shared/lib/timestamp-utilities'); 6 | 7 | // `X-Date-Temporal-Context` indicates the temporal context of the content, whether it 8 | // is related to past, present, or future *day*. 9 | // 10 | // This is useful for caching purposes so you can heavily cache past content, but not 11 | // present/future. 12 | function setHeadersForDateTemporalContext({ res, nowTs, comparedToUrlDate: { yyyy, mm, dd } }) { 13 | assert(res); 14 | assert(Number.isInteger(nowTs)); 15 | assert(Number.isInteger(yyyy)); 16 | assert(Number.isInteger(mm)); 17 | assert(Number.isInteger(dd)); 18 | 19 | // We use the start of the UTC day so we can compare apples to apples with a new date 20 | // constructed with yyyy-mm-dd (no time occured since the start of the day) 21 | const startOfTodayTs = getUtcStartOfDayTs(nowTs); 22 | const compareTs = Date.UTC(yyyy, mm, dd); 23 | 24 | let temporalContext = 'present'; 25 | if (compareTs < startOfTodayTs) { 26 | temporalContext = 'past'; 27 | } else if (compareTs > startOfTodayTs) { 28 | temporalContext = 'future'; 29 | } 30 | 31 | res.set('X-Date-Temporal-Context', temporalContext); 32 | } 33 | 34 | module.exports = setHeadersForDateTemporalContext; 35 | -------------------------------------------------------------------------------- /server/lib/set-headers-to-preload-assets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | 5 | const getDependenciesForEntryPointName = require('../lib/get-dependencies-for-entry-point-name'); 6 | 7 | // Set some preload link headers which we can use with Cloudflare to turn into 103 early 8 | // hints, https://developers.cloudflare.com/cache/about/early-hints/ 9 | // 10 | // This will turn into a nice speed-up since the server side render can take some time 11 | // while we fetch all the information from the homeserver and the page can have all of 12 | // the assets loaded and ready to go by that time. This way there is no extra delay 13 | // after the page gets served. 14 | function setHeadersToPreloadAssets(res, pageOptions) { 15 | assert(res); 16 | assert(pageOptions); 17 | assert(pageOptions.entryPoint); 18 | 19 | const { styles, fonts, images, preloadScripts } = getDependenciesForEntryPointName( 20 | pageOptions.entryPoint 21 | ); 22 | 23 | // Work on assembling the `Link` headers 24 | // 25 | // Note: Any docs for the `` element apply to the `Link` header. "The `Link` 26 | // header contains parameters [that] are equivalent to attributes of the `` 27 | // element." 28 | // (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link#parameters) 29 | // 30 | // XXX: Should we add `nopush` to the `Link` headers here? Many servers initiate an 31 | // HTTP/2 Server Push when they encounter a preload link in HTTP header form 32 | // otherwise. Do we want/care about that (or maybe we don't)? (mentioned in 33 | // https://medium.com/reloading/preload-prefetch-and-priorities-in-chrome-776165961bbf#6f54) 34 | 35 | const styleLinks = styles.map((styleUrl) => { 36 | return `<${styleUrl}>; rel=preload; as=style`; 37 | }); 38 | 39 | // We use `crossorigin` because fonts are fetched with anonymous mode "cors" and 40 | // "same-origin" credentials mode (see 41 | // https://drafts.csswg.org/css-fonts/#font-fetching-requirements). `crossorigin` is 42 | // just short-hand for `crossorigin=anonymous` (see 43 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin and 44 | // https://html.spec.whatwg.org/multipage/infrastructure.html#cors-settings-attribute). 45 | const fontLinks = fonts.map((fontUrl) => { 46 | return `<${fontUrl}>; rel=preload; as=font; crossorigin`; 47 | }); 48 | 49 | const imageLinks = images.map((imageUrl) => { 50 | return `<${imageUrl}>; rel=preload; as=image`; 51 | }); 52 | 53 | // We use `rel=modulepreload` instead of `rel=preload` for the JavaScript modules 54 | // because it's a nice dedicated thing to handle ESM modules that not only downloads 55 | // and puts it in the cache like a normal `rel=preload` but the browser also knows 56 | // it's a JavaScript module now and can parse/compile it so it's ready to go. 57 | // 58 | // Also as a note: `