diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..836be7f --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,29 @@ +name: Build and Push Matrix-to Docker Image + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Log in to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + push: true + tags: domoel/matrix-to:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbf96be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Stage 1: Build +FROM node:20.2-alpine AS build +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install +COPY . . +RUN yarn build + +# Stage 2: Production +FROM nginx:alpine +WORKDIR /etc/nginx +COPY ./nginx.conf /etc/nginx/nginx.conf +WORKDIR /usr/share/nginx/html +COPY --from=build /app/build . + +# Expose port 80 +EXPOSE 80 + +# Healthcheck +HEALTHCHECK CMD curl --fail http://localhost:80 || exit 1 + +# Start Nginx server +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 3f67274..5ac8c68 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,29 @@ https://matrix.to/#/#matrix:matrix.org?web-instance[element.io]=chat.mozilla.org You can discuss matrix.to in [`#matrix.to:matrix.org`](https://matrix.to/#/#matrix.to:matrix.org) + ## Build Instructions +### Native build + 1. Install [yarn](https://classic.yarnpkg.com/en/docs/install) 1. `git clone https://github.com/matrix-org/matrix.to` 1. `cd matrix.to` 1. `yarn` 1. `yarn start` 1. Go to http://localhost:5000 in your browser + +### Build with docker compose + +``` +version: '3.8' + +services: + matrix-to: + container_name: Matrix-to + image: domoel/matrix-to:latest + ports: + - "1336:80" + environment: + - NODE_ENV=production +``` diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..fe9fffa --- /dev/null +++ b/compose.yaml @@ -0,0 +1,10 @@ +version: '3.8' + +services: + matrix-to: + container_name: Matrix-to + image: domoel/matrix-to:latest + ports: + - "1336:80" + environment: + - NODE_ENV=production diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..9aecdb7 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,34 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + server { + listen 80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + error_page 404 /404.html; + location = /404.html { + root /usr/share/nginx/html; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } +} \ No newline at end of file diff --git a/src/Preferences.js b/src/Preferences.js index d6d36f6..d365230 100644 --- a/src/Preferences.js +++ b/src/Preferences.js @@ -25,6 +25,7 @@ export class Preferences extends EventEmitter { // used to differentiate web from native if a client supports both this.platform = null; this.homeservers = null; + this.customWebInstances = {}; const prefsStr = localStorage.getItem("preferred_client"); if (prefsStr) { @@ -36,6 +37,10 @@ export class Preferences extends EventEmitter { if (serversStr) { this.homeservers = JSON.parse(serversStr); } + const customWebInstancesStr = localStorage.getItem("custom_web_instances"); + if (customWebInstancesStr) { + this.customWebInstances = JSON.parse(customWebInstancesStr); + } } setClient(id, platform) { @@ -54,15 +59,27 @@ export class Preferences extends EventEmitter { } } + setCustomWebInstance(client_id, instance_url) { + this.customWebInstances[client_id] = instance_url; + this._localStorage.setItem("custom_web_instances", JSON.stringify(this.customWebInstances)); + this.emit("canClear"); + } + + getCustomWebInstance(client_id) { + return this.customWebInstances[client_id]; + } + clear() { this._localStorage.removeItem("preferred_client"); this._localStorage.removeItem("consented_servers"); + this._localStorage.removeItem("custom_web_instances"); this.clientId = null; this.platform = null; this.homeservers = null; + this.customWebInstances = {}; } get canClear() { - return !!this.clientId || !!this.platform || !!this.homeservers; + return !!this.clientId || !!this.platform || !!this.homeservers || !!this.customWebInstances; } } diff --git a/src/open/ClientView.js b/src/open/ClientView.js index 76c73e9..c73b682 100644 --- a/src/open/ClientView.js +++ b/src/open/ClientView.js @@ -39,6 +39,14 @@ function renderInstructions(parts) { export class ClientView extends TemplateView { render(t, vm) { + return t.mapView(vm => vm.customWebInstanceFormOpen, open => { + switch (open) { + case true: return new SetCustomWebInstanceView(vm); + case false: return new TemplateView(vm, t => this.renderContent(t, vm)); + } + }); + } + renderContent(t, vm) { return t.div({className: {"ClientView": true, "isPreferred": vm => vm.hasPreferredWebInstance}}, [ ... vm.hasPreferredWebInstance ? [t.div({className: "hostedBanner"}, vm.hostedByBannerLabel)] : [], t.div({className: "header"}, [ @@ -112,10 +120,49 @@ class InstallClientView extends TemplateView { } } +export class SetCustomWebInstanceView extends TemplateView { + render(t, vm) { + return t.div({className: "SetCustomWebInstanceView"}, [ + t.p([ + "Use a custom web instance for the ", t.strong(vm.name), " client:", + ]), + t.form({action: "#", id: "setCustomWebInstanceForm", onSubmit: evt => this._onSubmit(evt)}, [ + t.input({ + type: "text", + className: "fullwidth large", + placeholder: "chat.example.org", + name: "instanceHostname", + value: vm.preferredWebInstance || "", + }), + t.input({type: "submit", value: "Save", className: "primary fullwidth"}), + t.input({type: "button", value: "Use Default Instance", className: "secondary fullwidth", onClick: evt => this._onReset(evt)}), + ]) + ]); + } + + _onSubmit(evt) { + evt.preventDefault(); + const form = evt.target; + const {instanceHostname} = form.elements; + this.value.setCustomWebInstance(instanceHostname.value); + this.value.closeCustomWebInstanceForm(); + } + + _onReset(evt) { + this.value.setCustomWebInstance(undefined); + this.value.closeCustomWebInstanceForm(); + } +} + function showBack(t, vm) { return t.p({className: {caption: true, "back": true, hidden: vm => !vm.showBack}}, [ `Continue with ${vm.name} · `, t.button({className: "text", onClick: () => vm.back()}, "Change"), + t.span({hidden: vm => !vm.supportsCustomWebInstances}, [ + ' · ', + t.button({className: "text", onClick: () => vm.configureCustomWebInstance()}, "Use Custom Web Instance"), + ]) + ]); } diff --git a/src/open/ClientViewModel.js b/src/open/ClientViewModel.js index 2cd7244..210e30b 100644 --- a/src/open/ClientViewModel.js +++ b/src/open/ClientViewModel.js @@ -35,6 +35,7 @@ export class ClientViewModel extends ViewModel { this._pickClient = pickClient; // to provide "choose other client" button after calling pick() this._clientListViewModel = null; + this.customWebInstanceFormOpen = false; this._update(); } @@ -59,11 +60,11 @@ export class ClientViewModel extends ViewModel { if (this._proposedPlatform === this._nativePlatform) { deepLinkLabel = "Open in app"; } else { - deepLinkLabel = `Open on ${this._client.getPreferredWebInstance(this._link)}`; + deepLinkLabel = `Open on ${this.preferredWebInstance}`; } } const actions = []; - const proposedDeepLink = this._client.getDeepLink(this._proposedPlatform, this._link); + const proposedDeepLink = this._client.getDeepLink(this._proposedPlatform, this._link, this.preferredWebInstance); if (proposedDeepLink) { actions.push({ label: deepLinkLabel, @@ -83,8 +84,8 @@ export class ClientViewModel extends ViewModel { // show only if there is a preferred instance, and if we don't already link to it in the first button if (hasPreferredWebInstance && this._webPlatform && this._proposedPlatform !== this._webPlatform) { actions.push({ - label: `Open on ${this._client.getPreferredWebInstance(this._link)}`, - url: this._client.getDeepLink(this._webPlatform, this._link), + label: `Open on ${this.preferredWebInstance}`, + url: this._client.getDeepLink(this._webPlatform, this._link, this.preferredWebInstance), kind: "open-in-web", activated: () => {} // don't persist this choice as we don't persist the preferred web instance, it's in the url }); @@ -108,10 +109,10 @@ export class ClientViewModel extends ViewModel { actions.push(...nativeActions); } if (this._webPlatform) { - const webDeepLink = this._client.getDeepLink(this._webPlatform, this._link); + const webDeepLink = this._client.getDeepLink(this._webPlatform, this._link, this.preferredWebInstance); if (webDeepLink) { const webLabel = this.hasPreferredWebInstance ? - `Open on ${this._client.getPreferredWebInstance(this._link)}` : + `Open on ${this.preferredWebInstance}` : `Continue in your browser`; actions.push({ label: webLabel, @@ -128,18 +129,26 @@ export class ClientViewModel extends ViewModel { return actions; } - get hasPreferredWebInstance() { + get preferredWebInstance() { // also check there is a web platform that matches the platforms the user is on (mobile or desktop web) - return this._webPlatform && typeof this._client.getPreferredWebInstance(this._link) === "string"; + if (!this._webPlatform) return undefined; + return ( + this.preferences.getCustomWebInstance(this._client.id) + || this._client.getPreferredWebInstance(this._link) + ); + } + + get hasPreferredWebInstance() { + return typeof this.preferredWebInstance === "string"; } get hostedByBannerLabel() { - const preferredWebInstance = this._client.getPreferredWebInstance(this._link); - if (this._webPlatform && preferredWebInstance) { + if (this.hasPreferredWebInstance) { + const preferredWebInstance = this.preferredWebInstance; let label = preferredWebInstance; - const subDomainIdx = preferredWebInstance.lastIndexOf(".", preferredWebInstance.lastIndexOf(".")); + const subDomainIdx = preferredWebInstance.lastIndexOf(".", preferredWebInstance.lastIndexOf(".") - 1); if (subDomainIdx !== -1) { - label = preferredWebInstance.slice(preferredWebInstance.length - subDomainIdx + 1); + label = preferredWebInstance.slice(subDomainIdx + 1); } return `Hosted by ${label}`; } @@ -188,7 +197,7 @@ export class ClientViewModel extends ViewModel { get showDeepLinkInInstall() { // we can assume this._nativePlatform as this._clientCanIntercept already checks it - return this._clientCanIntercept && !!this._client.getDeepLink(this._nativePlatform, this._link); + return this._clientCanIntercept && !!this._client.getDeepLink(this._nativePlatform, this._link, this.preferredWebInstance); } get availableOnPlatformNames() { @@ -223,6 +232,10 @@ export class ClientViewModel extends ViewModel { return !!this._clientListViewModel; } + get supportsCustomWebInstances() { + return !!this._client.supportsCustomInstances; + } + back() { if (this._clientListViewModel) { const vm = this._clientListViewModel; @@ -231,9 +244,29 @@ export class ClientViewModel extends ViewModel { // in the list with all clients, and also if we refresh, we get the list with // all clients rather than having our "change client" click reverted. this.preferences.setClient(undefined, undefined); + this.preferences.setCustomWebInstance(this._client.id, undefined); this._update(); this.emitChange(); vm.showAll(); } } + + configureCustomWebInstance() { + this.customWebInstanceFormOpen = true; + this.emitChange(); + } + + closeCustomWebInstanceForm() { + this.customWebInstanceFormOpen = false; + this.emitChange(); + } + + setCustomWebInstance(hostname) { + if (hostname) { + hostname = hostname.trim().replace(/^https:\/\//, '').replace(/\/.*$/, ''); + } + this.preferences.setClient(this._client.id, hostname ? this._webPlatform : (this._nativePlatform || this._webPlatform)); + this.preferences.setCustomWebInstance(this._client.id, hostname || undefined); + this._update(); + } } diff --git a/src/open/clients/Element.js b/src/open/clients/Element.js index 6cdf81a..bd91c07 100644 --- a/src/open/clients/Element.js +++ b/src/open/clients/Element.js @@ -56,8 +56,9 @@ export class Element { get homepage() { return "https://element.io"; } get author() { return "Element"; } getMaturity(platform) { return Maturity.Stable; } + get supportsCustomInstances() { return true; } - getDeepLink(platform, link) { + getDeepLink(platform, link, preferredWebInstance) { let fragmentPath; switch (link.kind) { case LinkKind.User: @@ -83,8 +84,8 @@ export class Element { let instanceHost = trustedWebInstances[0]; // we use app.element.io which iOS will intercept, but it likely won't intercept any other trusted instances // so only use a preferred web instance for true web links. - if (isWebPlatform && trustedWebInstances.includes(link.webInstances[this.id])) { - instanceHost = link.webInstances[this.id]; + if (isWebPlatform && preferredWebInstance) { + instanceHost = preferredWebInstance; } return `https://${instanceHost}/#/${fragmentPath}`; } else if (platform === Platform.Linux || platform === Platform.Windows || platform === Platform.macOS) {