This commit is contained in:
Eugene Pankov 2021-10-31 18:15:23 +01:00
commit f677febac3
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
134 changed files with 11509 additions and 0 deletions

12
.bumpversion.cfg Normal file
View File

@ -0,0 +1,12 @@
[bumpversion]
current_version = 1.0.0
commit = True
tag = True
[bumpversion:file:frontend/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:backend/pyproject.toml]
search = version = "{current_version}"
replace = version = "{new_version}"

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.ts]
indent_size = 2

7
.flake8 Normal file
View File

@ -0,0 +1,7 @@
[flake8]
ignore=E501,D103,C901,D203,W504,S607,S603,S404,S606,S322,S410,S320,B010
exclude = .git,__pycache__,help,static,misc,locale,templates,tests,deployment,migrations,elements/ai/scripts
max-complexity = 40
builtins = _
per-file-ignores = scripts/*:T001,E402
select = C,E,F,W,B,B902

50
.github/workflows/docker-backend.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: Docker (Backend)
on:
schedule:
- cron: '25 12 * * *'
push:
branches: [ master ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
pull_request:
branches: [ master ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: eugeny/tabby-web-backend
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: backend
build-args: EXTRA_DEPS=gcsfs
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

50
.github/workflows/docker-frontend.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: Docker (Frontend)
on:
schedule:
- cron: '25 12 * * *'
push:
branches: [ master ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
pull_request:
branches: [ master ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: eugeny/tabby-web-frontend
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: frontend
build-args: EXTRA_DEPS=gcsfs
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

36
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: Lint
on: [push, pull_request]
jobs:
Lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2.3.4
with:
fetch-depth: 0
- name: Installing Node
uses: actions/setup-node@v2.4.0
with:
node-version: 14
- name: Install frontend deps
working-directory: frontend
run: |
npm i -g yarn@1.19.1
yarn
- name: Lint frontend
working-directory: frontend
run: yarn lint
- name: Install backend deps
working-directory: backend
run: |
pip3 install poetry
poetry install
- name: Lint backend
working-directory: backend
run: poetry run flake8 .

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.vscode
.env
app-dist
.mypy_cache

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017 Eugeny
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

66
README.md Normal file
View File

@ -0,0 +1,66 @@
# Tabby Web
![](docs/screenshot.png)
This is the exact code that runs at https://tabby.sh. In fact, it's being deployed straight out of this repository.
You can use this to deploy your own copy or to make improvements - pull requests are welcome!
# How it works
Tabby Web serves the Tabby Terminal as a web application while managing multiple config files, authentication, and providing TCP connections via a [separate gateway service](https://github.com/Eugeny/tabby-connection-gateway).
# Requirements
* Python 3.7+
* A database server supported by Django (MariaDB, Postgres, SQLite, etc.)
* Storage for distribution files - local, S3, GCS or others supported by `fsspec`
# Using Docker images
Tabby Web consists of two Docker images - `backend` and `frontend`. See an example set up in `docker-compose.yml`
## Environment variables
### Frontend
* `BACKEND_URL` (required if running the backend in a separate Docker container).
* `WEB_CONCURRENCY`
### Backend
* `DATABASE_URL` (required).
* `FRONTEND_URL`
* `APP_DIST_STORAGE`: a `file://`, `s3://`, or `gcs://` URL to store app distros in.
* `SOCIAL_AUTH_*_KEY` & `SOCIAL_AUTH_*_SECRET`: social login credentials, supported providers are `GITHUB`, `GITLAB`, `MICROSOFT_GRAPH` and `GOOGLE_OAUTH2`.
## Adding Tabby app versions
* `docker-compose run backend ./manage.py add_version 1.0.156-nightly.2`
# Development setup
Put your environment vars (`DATABASE_URL`, etc.) in the `.env` file in the root of the repo.
For the frontend:
```shell
cd frontend
yarn
yarn run build # or yarn run watch
```
For the backend:
```shell
cd backend
poetry install
./manage.py migrate # set up the database
./manage.py add_version 1.0.156-nightly.2 # install an app distribution
PORT=9000 poetry run gunicorn # optionally with --reload
```
# Security
* When using Tabby Web for SSH/Telnet connectivity, your traffic will pass through a hosted gateway service. It's encrypted in transit (HTTPS) and the gateway servers authenticate themselves with a certificate before connections are made. However there's a non-zero risk of a MITM if a gateway service is compromised and the attacker gains access to the service's private key.
* You can alleviate this risk by [hosting your own gateway service](https://github.com/Eugeny/tabby-connection-gateway), or your own copy of Tabby Web altogether.

1
backend/.dockerignore Normal file
View File

@ -0,0 +1 @@
__pycache__

7
backend/.flake8 Normal file
View File

@ -0,0 +1,7 @@
[flake8]
ignore=E501,D103,C901,D203,W504,S607,S603,S404,S606,S322,S410,S320,B010
exclude = .git,__pycache__,help,static,misc,locale,templates,tests,deployment,migrations,elements/ai/scripts
max-complexity = 40
builtins = _
per-file-ignores = scripts/*:T001,E402
select = C,E,F,W,B,B902

3
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
__pycache__
db.sqlite3
public

22
backend/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
FROM python:3.7-alpine AS build
ARG EXTRA_DEPS
RUN apk add build-base musl-dev libffi-dev openssl-dev mariadb-dev
WORKDIR /app
RUN pip install -U setuptools 'cryptography>=3.0,<3.1' poetry==1.1.7 $EXTRA_DEPS
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false
RUN poetry install --no-dev --no-ansi --no-interaction
FROM python:3.7-alpine AS package
WORKDIR /app
COPY --from=0 /usr /usr
COPY manage.py gunicorn.conf.py ./
COPY tabby tabby
COPY start.sh /start.sh
RUN ["chmod", "+x", "/start.sh"]
RUN ./manage.py collectstatic --noinput
CMD ["/start.sh"]

14
backend/cloudbuild.yaml Normal file
View File

@ -0,0 +1,14 @@
steps:
- name: 'gcr.io/cloud-builders/docker'
dir: 'backend'
args:
- build
- '-t'
- '${_DOCKER_TAG}'
- '--cache-from'
- '${_DOCKER_TAG}'
- '--build-arg'
- 'EXTRA_DEPS=${_EXTRA_DEPS}'
- '.'
images: ['${_DOCKER_TAG}']

7
backend/gunicorn.conf.py Normal file
View File

@ -0,0 +1,7 @@
wsgi_app = "tabby.wsgi:application"
workers = 4
preload_app = True
sendfile = True
max_requests = 1000
max_requests_jitter = 100

22
backend/manage.py Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tabby.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

1023
backend/poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

33
backend/pyproject.toml Normal file
View File

@ -0,0 +1,33 @@
[tool.poetry]
name = "tabby-web"
version = "1.0.0"
description = ""
authors = ["Eugeny <e@ajenti.org>"]
[tool.poetry.dependencies]
python = "^3.7"
Django = "^3.2.3"
django-rest-framework = "^0.1.0"
djangorestframework-dataclasses = "^0.9"
social-auth-app-django = "^4.0.0"
python-dotenv = "^0.17.1"
websockets = "^9.1"
gql = "^2.0.0"
dj-database-url = "^0.5.0"
mysqlclient = "^2.0.3"
gunicorn = "^20.1.0"
Twisted = "20.3.0"
semver = "^2.13.0"
requests = "^2.25.1"
pyga = "^2.6.2"
django-cors-headers = "^3.7.0"
cryptography = "3.0"
fsspec = "^2021.7.0"
whitenoise = "^5.3.0"
[tool.poetry.dev-dependencies]
flake8 = "^3.9.2"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

4
backend/start.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
cd /app
./manage.py migrate
gunicorn

View File

View File

View File

@ -0,0 +1,14 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import Gateway, User, Config
class CustomUserAdmin(UserAdmin):
fieldsets = UserAdmin.fieldsets + (
(None, {'fields': ('custom_connection_gateway', 'custom_connection_gateway_token')}),
)
admin.site.register(User, CustomUserAdmin)
admin.site.register(Config)
admin.site.register(Gateway)

View File

@ -0,0 +1,18 @@
from django.urls import path, include
from rest_framework import routers
from . import app_version, auth, config, gateway, info, user
router = routers.DefaultRouter(trailing_slash=False)
router.register('api/1/configs', config.ConfigViewSet)
router.register('api/1/versions', app_version.AppVersionViewSet, basename='app-versions')
urlpatterns = [
path('api/1/auth/logout', auth.LogoutView.as_view()),
path('api/1/user', user.UserViewSet.as_view({'get': 'retrieve', 'put': 'update'})),
path('api/1/instance-info', info.InstanceInfoViewSet.as_view({'get': 'retrieve'})),
path('api/1/gateways/choose', gateway.ChooseGatewayViewSet.as_view({'post': 'retrieve'})),
path('', include(router.urls)),
]

View File

@ -0,0 +1,64 @@
import fsspec
import os
from django.conf import settings
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from dataclasses import dataclass
from rest_framework.response import Response
from rest_framework.mixins import ListModelMixin
from rest_framework.viewsets import GenericViewSet
from rest_framework_dataclasses.serializers import DataclassSerializer
from typing import List
from urllib.parse import urlparse
@dataclass
class AppVersion:
version: str
plugins: List[str]
class AppVersionSerializer(DataclassSerializer):
class Meta:
dataclass = AppVersion
class AppVersionViewSet(ListModelMixin, GenericViewSet):
serializer_class = AppVersionSerializer
lookup_field = 'id'
lookup_value_regex = r'[\w\d.-]+'
queryset = ''
def _get_versions(self):
fs = fsspec.filesystem(urlparse(settings.APP_DIST_STORAGE).scheme)
return [
self._get_version(x['name'])
for x in fs.listdir(settings.APP_DIST_STORAGE)
if x['type'] == 'directory'
]
def _get_version(self, dir):
fs = fsspec.filesystem(urlparse(settings.APP_DIST_STORAGE).scheme)
plugins = [
os.path.basename(x['name'])
for x in fs.listdir(dir)
if x['type'] == 'directory' and os.path.basename(x['name'])
not in [
'tabby-web-container',
'tabby-web-demo',
]
]
return AppVersion(
version=os.path.basename(dir),
plugins=plugins,
)
@method_decorator(cache_page(60))
def list(self, request, *args, **kwargs):
return Response(
self.serializer_class(
self._get_versions(),
many=True,
).data
)

View File

@ -0,0 +1,9 @@
from django.contrib.auth import logout
from rest_framework.response import Response
from rest_framework.views import APIView
class LogoutView(APIView):
def post(self, request, format=None):
logout(request)
return Response(None)

View File

@ -0,0 +1,28 @@
from rest_framework import fields
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet
from rest_framework.serializers import ModelSerializer
from ..models import Config
class ConfigSerializer(ModelSerializer):
name = fields.CharField(required=False)
class Meta:
model = Config
read_only_fields = ('user', 'created_at', 'modified_at')
fields = '__all__'
class ConfigViewSet(ModelViewSet):
queryset = Config.objects.all()
serializer_class = ConfigSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
if self.request.user.is_authenticated:
return Config.objects.filter(user=self.request.user)
return Config.objects.none()
def perform_create(self, serializer):
serializer.save(user=self.request.user)

View File

@ -0,0 +1,59 @@
import asyncio
import random
from rest_framework import fields, status
from rest_framework.exceptions import APIException, NotFound
from rest_framework.mixins import RetrieveModelMixin
from rest_framework.viewsets import GenericViewSet
from rest_framework.serializers import ModelSerializer
from ..gateway import GatewayAdminConnection
from ..models import Gateway
class GatewaySerializer(ModelSerializer):
url = fields.SerializerMethodField()
auth_token = fields.CharField()
class Meta:
fields = '__all__'
model = Gateway
def get_url(self, gw):
return f'{"wss" if gw.secure else "ws"}://{gw.host}:{gw.port}/'
class NoGatewaysError(APIException):
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
default_detail = 'No connection gateways available.'
default_code = 'no_gateways'
class ChooseGatewayViewSet(RetrieveModelMixin, GenericViewSet):
queryset = Gateway.objects.filter(enabled=True)
serializer_class = GatewaySerializer
async def _authorize_client(self, gw):
c = GatewayAdminConnection(gw)
await c.connect()
token = await c.authorize_client()
await c.close()
return token
def get_object(self):
gateways = list(self.queryset)
random.shuffle(gateways)
if not len(gateways):
raise NotFound()
loop = asyncio.new_event_loop()
try:
for gw in gateways:
try:
gw.auth_token = loop.run_until_complete(self._authorize_client(gw))
except ConnectionError as e:
print(e)
continue
return gw
raise NoGatewaysError()
finally:
loop.close()

View File

@ -0,0 +1,21 @@
from django.conf import settings
from rest_framework import fields
from rest_framework.mixins import RetrieveModelMixin
from rest_framework.viewsets import GenericViewSet
from rest_framework.serializers import Serializer
class InstanceInfoSerializer(Serializer):
login_enabled = fields.BooleanField()
homepage_enabled = fields.BooleanField()
class InstanceInfoViewSet(RetrieveModelMixin, GenericViewSet):
queryset = '' # type: ignore
serializer_class = InstanceInfoSerializer
def get_object(self):
return {
'login_enabled': settings.ENABLE_LOGIN,
'homepage_enabled': settings.ENABLE_HOMEPAGE,
}

View File

@ -0,0 +1,55 @@
from django.conf import settings
from rest_framework import fields
from rest_framework.exceptions import PermissionDenied
from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin
from rest_framework.viewsets import GenericViewSet
from rest_framework.serializers import ModelSerializer
from social_django.models import UserSocialAuth
from ..sponsors import check_is_sponsor_cached
from ..models import User
class UserSerializer(ModelSerializer):
id = fields.IntegerField()
is_pro = fields.SerializerMethodField()
is_sponsor = fields.SerializerMethodField()
github_username = fields.SerializerMethodField()
class Meta:
model = User
fields = (
'id',
'username',
'active_config',
'custom_connection_gateway',
'custom_connection_gateway_token',
'config_sync_token',
'is_pro',
'is_sponsor',
'github_username',
)
read_only_fields = ('id', 'username')
def get_is_pro(self, obj):
return obj.force_pro or not settings.GITHUB_ELIGIBLE_SPONSORSHIPS or check_is_sponsor_cached(obj)
def get_is_sponsor(self, obj):
return check_is_sponsor_cached(obj)
def get_github_username(self, obj):
social_auth = UserSocialAuth.objects.filter(user=obj, provider='github').first()
if not social_auth:
return None
return social_auth.extra_data.get('login')
class UserViewSet(RetrieveModelMixin, UpdateModelMixin, GenericViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
def get_object(self):
if self.request.user.is_authenticated:
return self.request.user
raise PermissionDenied()

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'tabby.app'

View File

@ -0,0 +1,92 @@
import asyncio
import json
import os
import secrets
import ssl
import websockets
from django.conf import settings
from urllib.parse import quote
from .models import Gateway
class GatewayConnection:
_ssl_context: ssl.SSLContext = None
def __init__(self, host: str, port: int):
if settings.CONNECTION_GATEWAY_AUTH_KEY and not GatewayConnection._ssl_context:
ctx = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain(
os.path.realpath(settings.CONNECTION_GATEWAY_AUTH_CERTIFICATE),
os.path.realpath(settings.CONNECTION_GATEWAY_AUTH_KEY),
)
if settings.CONNECTION_GATEWAY_AUTH_CA:
ctx.load_verify_locations(
cafile=os.path.realpath(settings.CONNECTION_GATEWAY_AUTH_CA),
)
ctx.verify_mode = ssl.CERT_REQUIRED
GatewayConnection._ssl_context = ctx
proto = 'wss' if GatewayConnection._ssl_context else 'ws'
self.url = f'{proto}://localhost:9000/connect/{quote(host)}:{quote(str(port))}'
async def connect(self):
self.context = websockets.connect(self.url, ssl=GatewayConnection._ssl_context)
try:
self.socket = await self.context.__aenter__()
except OSError:
raise ConnectionError()
async def send(self, data):
await self.socket.send(data)
def recv(self, timeout=None):
return asyncio.wait_for(self.socket.recv(), timeout)
async def close(self):
await self.socket.close()
await self.context.__aexit__(None, None, None)
class GatewayAdminConnection:
_ssl_context: ssl.SSLContext = None
def __init__(self, gateway: Gateway):
if not settings.CONNECTION_GATEWAY_AUTH_KEY:
raise RuntimeError('CONNECTION_GATEWAY_AUTH_KEY is required to manage connection gateways')
if not GatewayAdminConnection._ssl_context:
ctx = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain(
os.path.realpath(settings.CONNECTION_GATEWAY_AUTH_CERTIFICATE),
os.path.realpath(settings.CONNECTION_GATEWAY_AUTH_KEY),
)
if settings.CONNECTION_GATEWAY_AUTH_CA:
ctx.load_verify_locations(
cafile=os.path.realpath(settings.CONNECTION_GATEWAY_AUTH_CA),
)
ctx.verify_mode = ssl.CERT_REQUIRED
GatewayAdminConnection._ssl_context = ctx
self.url = f'wss://{gateway.host}:{gateway.admin_port}'
async def connect(self):
self.context = websockets.connect(self.url, ssl=GatewayAdminConnection._ssl_context)
try:
self.socket = await self.context.__aenter__()
except OSError:
raise ConnectionError()
async def authorize_client(self) -> str:
token = secrets.token_hex(32)
await self.send(json.dumps({
'_': 'authorize-client',
'token': token,
}))
return token
async def send(self, data):
await self.socket.send(data)
async def close(self):
await self.socket.close()
await self.context.__aexit__(None, None, None)

View File

View File

@ -0,0 +1,65 @@
import fsspec
import logging
import requests
import shutil
import subprocess
import tempfile
from django.core.management.base import BaseCommand
from django.conf import settings
from pathlib import Path
from urllib.parse import urlparse
class Command(BaseCommand):
help = 'Downloads a new app version'
def add_arguments(self, parser):
parser.add_argument('version', type=str)
def handle(self, *args, **options):
version = options['version']
target = f'{settings.APP_DIST_STORAGE}/{version}'
fs = fsspec.filesystem(urlparse(settings.APP_DIST_STORAGE).scheme)
plugin_list = [
'tabby-web-container',
'tabby-core',
'tabby-settings',
'tabby-terminal',
'tabby-ssh',
'tabby-community-color-schemes',
'tabby-serial',
'tabby-telnet',
'tabby-web',
'tabby-web-demo',
]
with tempfile.TemporaryDirectory() as tempdir:
tempdir = Path(tempdir)
for plugin in plugin_list:
logging.info(f'Resolving {plugin}@{version}')
response = requests.get(f'{settings.NPM_REGISTRY}/{plugin}/{version}')
response.raise_for_status()
info = response.json()
url = info['dist']['tarball']
logging.info(f'Downloading {plugin}@{version} from {url}')
response = requests.get(url)
with tempfile.NamedTemporaryFile('wb') as f:
f.write(response.content)
plugin_final_target = Path(tempdir) / plugin
with tempfile.TemporaryDirectory() as extraction_tmp:
subprocess.check_call(
['tar', '-xzf', f.name, '-C', str(extraction_tmp)]
)
shutil.move(
Path(extraction_tmp) / 'package', plugin_final_target
)
if fs.exists(target):
fs.rm(target, recursive=True)
fs.mkdir(target)
fs.put(str(tempdir), target, recursive=True)

View File

@ -0,0 +1,75 @@
# Generated by Django 3.2.3 on 2021-07-08 17:43
from django.conf import settings
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('active_version', models.CharField(max_length=32, null=True)),
('custom_connection_gateway', models.CharField(max_length=255, null=True, blank=True)),
('custom_connection_gateway_token', models.CharField(max_length=255, null=True, blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='Config',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(default='{}')),
('last_used_with_version', models.CharField(max_length=32, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='configs', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='user',
name='active_config',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='app.config'),
),
migrations.AddField(
model_name='user',
name='groups',
field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'),
),
migrations.AddField(
model_name='user',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.3 on 2021-07-08 20:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Gateway',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('host', models.CharField(max_length=255)),
('port', models.IntegerField(default=1234)),
('enabled', models.BooleanField(default=True)),
('secure', models.BooleanField(default=True)),
],
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.2.3 on 2021-07-11 18:55
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('app', '0002_gateway'),
]
operations = [
migrations.AddField(
model_name='gateway',
name='admin_port',
field=models.IntegerField(default=1235),
),
migrations.AlterField(
model_name='user',
name='active_config',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='app.config'),
),
]

View File

@ -0,0 +1,29 @@
import secrets
from django.db import migrations, models
def run_forward(apps, schema_editor):
for user in apps.get_model('app', 'User').objects.all():
user.config_sync_token = secrets.token_hex(64)
user.save()
class Migration(migrations.Migration):
dependencies = [
('app', '0003_auto_20210711_1855'),
]
operations = [
migrations.AddField(
model_name='user',
name='config_sync_token',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.RunPython(run_forward, lambda _, __: None),
migrations.AlterField(
model_name='user',
name='config_sync_token',
field=models.CharField(max_length=255),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.3 on 2021-07-24 10:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0004_sync_token'),
]
operations = [
migrations.AddField(
model_name='user',
name='force_pro',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,28 @@
from django.db import migrations, models
def run_forward(apps, schema_editor):
for config in apps.get_model('app', 'Config').objects.all():
config.name = f'Unnamed config ({config.created_at.date()})'
config.save()
class Migration(migrations.Migration):
dependencies = [
('app', '0005_user_force_pro'),
]
operations = [
migrations.AddField(
model_name='config',
name='name',
field=models.CharField(max_length=255, null=True),
),
migrations.RunPython(run_forward, lambda _, __: None),
migrations.AlterField(
model_name='config',
name='name',
field=models.CharField(max_length=255),
),
]

View File

View File

@ -0,0 +1,45 @@
import secrets
from datetime import date
from django.db import models
from django.contrib.auth.models import AbstractUser
class Config(models.Model):
user = models.ForeignKey('app.User', related_name='configs', on_delete=models.CASCADE)
name = models.CharField(max_length=255)
content = models.TextField(default='{}')
last_used_with_version = models.CharField(max_length=32, null=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if not self.name:
self.name = f'Unnamed config ({date.today()})'
super().save(*args, **kwargs)
class User(AbstractUser):
active_config = models.ForeignKey(Config, null=True, on_delete=models.SET_NULL, related_name='+')
active_version = models.CharField(max_length=32, null=True)
custom_connection_gateway = models.CharField(max_length=255, null=True, blank=True)
custom_connection_gateway_token = models.CharField(max_length=255, null=True, blank=True)
config_sync_token = models.CharField(max_length=255)
force_pro = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if not self.config_sync_token:
self.config_sync_token = secrets.token_hex(64)
super().save(*args, **kwargs)
class Gateway(models.Model):
host = models.CharField(max_length=255)
port = models.IntegerField(default=1234)
admin_port = models.IntegerField(default=1235)
enabled = models.BooleanField(default=True)
secure = models.BooleanField(default=True)
def __str__(self):
return f'{self.host}:{self.port}'

View File

@ -0,0 +1,80 @@
from django.conf import settings
from django.core.cache import cache
from gql import Client, gql
from gql.transport.requests import RequestsHTTPTransport
from social_django.models import UserSocialAuth
from .models import User
GQL_ENDPOINT = 'https://api.github.com/graphql'
CACHE_KEY = 'cached-sponsors:%s'
def check_is_sponsor(user: User) -> bool:
try:
token = user.social_auth.get(provider='github').extra_data.get('access_token')
except UserSocialAuth.DoesNotExist:
return False
if not token:
return False
client = Client(
transport=RequestsHTTPTransport(
url=GQL_ENDPOINT,
use_json=True,
headers={
'Authorization': f'Bearer {token}',
}
)
)
after = None
while True:
params = 'first: 1'
if after:
params += f', after:"{after}"'
query = '''
query {
viewer {
sponsorshipsAsSponsor(%s) {
pageInfo {
startCursor
hasNextPage
endCursor
}
totalRecurringMonthlyPriceInDollars
nodes {
sponsorable {
... on Organization { login }
... on User { login }
}
}
}
}
}
''' % (params,)
response = client.execute(gql(query))
info = response['viewer']['sponsorshipsAsSponsor']
after = info['pageInfo']['endCursor']
nodes = info['nodes']
if not len(nodes):
break
for node in nodes:
if node['sponsorable']['login'].lower() not in settings.GITHUB_ELIGIBLE_SPONSORSHIPS:
continue
if info['totalRecurringMonthlyPriceInDollars'] >= settings.GITHUB_SPONSORS_MIN_PAYMENT:
return True
return False
def check_is_sponsor_cached(user: User) -> bool:
cache_key = CACHE_KEY % user.id
if not cache.get(cache_key):
cache.set(cache_key, check_is_sponsor(user), timeout=30)
return cache.get(cache_key)

17
backend/tabby/app/urls.py Normal file
View File

@ -0,0 +1,17 @@
from django.urls import path, include
from . import api
from . import views
urlpatterns = [
*[
path(p, views.IndexView.as_view())
for p in ['', 'login', 'app', 'about', 'about/features']
],
path('app-dist/<version>/<path:path>', views.AppDistView.as_view()),
path('terminal', views.TerminalView.as_view()),
path('', include(api.urlpatterns)),
]

View File

@ -0,0 +1,34 @@
import fsspec
import os
from fsspec.implementations.local import LocalFileSystem
from django.conf import settings
from django.http.response import FileResponse, HttpResponseNotFound, HttpResponseRedirect
from django.views import static
from rest_framework.views import APIView
from urllib.parse import urlparse
class IndexView(APIView):
def get(self, request, format=None):
if settings.FRONTEND_URL:
return HttpResponseRedirect(settings.FRONTEND_URL)
return static.serve(request, 'index.html', document_root=str(settings.FRONTEND_BUILD_DIR))
class TerminalView(APIView):
def get(self, request, format=None):
response = static.serve(request, 'terminal.html', document_root=str(settings.FRONTEND_BUILD_DIR))
response['X-Frame-Options'] = 'SAMEORIGIN'
return response
class AppDistView(APIView):
def get(self, request, version=None, path=None, format=None):
fs = fsspec.filesystem(urlparse(settings.APP_DIST_STORAGE).scheme)
url = f'{settings.APP_DIST_STORAGE}/{version}/{path}'
if isinstance(fs, LocalFileSystem):
if not fs.exists(url):
return HttpResponseNotFound()
return FileResponse(fs.open(url), filename=os.path.basename(url))
else:
return HttpResponseRedirect(fs.url(url))

View File

@ -0,0 +1,53 @@
import logging
from tabby.app.models import User
from django.conf import settings
from django.contrib.auth import login
from pyga.requests import Tracker, Page, Session, Visitor
class BaseMiddleware:
def __init__(self, get_response):
self.get_response = get_response
class TokenMiddleware(BaseMiddleware):
def __call__(self, request):
token_value = None
if 'auth_token' in request.GET:
token_value = request.GET['auth_token']
if request.META.get('HTTP_AUTHORIZATION'):
token_type, *credentials = request.META['HTTP_AUTHORIZATION'].split()
if token_type == 'Bearer' and len(credentials):
token_value = credentials[0]
user = User.objects.filter(config_sync_token=token_value).first()
if user:
request.session.save = lambda *args, **kwargs: None
setattr(user, 'backend', 'django.contrib.auth.backends.ModelBackend')
login(request, user)
setattr(request, '_dont_enforce_csrf_checks', True)
response = self.get_response(request)
if user:
response.set_cookie = lambda *args, **kwargs: None
return response
class GAMiddleware(BaseMiddleware):
def __init__(self, get_response):
super().__init__(get_response)
if settings.GA_ID:
self.tracker = Tracker(settings.GA_ID, settings.GA_DOMAIN)
def __call__(self, request):
response = self.get_response(request)
if settings.GA_ID and request.path in ['/', '/app']:
try:
self.tracker.track_pageview(Page(request.path), Session(), Visitor())
except Exception:
logging.exception()
return response

261
backend/tabby/settings.py Normal file
View File

@ -0,0 +1,261 @@
import os
import dj_database_url
from dotenv import load_dotenv
from pathlib import Path
from urllib.parse import urlparse
load_dotenv()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_BUILD_DIR = BASE_DIR / '../frontend/build'
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'django-insecure')
DEBUG = bool(os.getenv('DEBUG', False))
ALLOWED_HOSTS = ['*']
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'social_django',
'corsheaders',
'tabby.app',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'corsheaders.middleware.CorsMiddleware',
'tabby.middleware.TokenMiddleware',
'tabby.middleware.GAMiddleware',
]
ROOT_URLCONF = 'tabby.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'tabby.wsgi.application'
DATABASES = {
'default': dj_database_url.config(conn_max_age=600)
}
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
AUTH_USER_MODEL = 'app.User'
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
)
}
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '%(levelname)s %(message)s'
},
},
'handlers': {
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'simple'
},
},
'loggers': {
'': {
'handlers': ['console'],
'propagate': False,
'level': 'INFO',
},
},
}
STATIC_URL = '/static/'
if FRONTEND_BUILD_DIR.exists():
STATICFILES_DIRS = [FRONTEND_BUILD_DIR]
STATIC_ROOT = BASE_DIR / 'public'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
CSRF_USE_SESSIONS = False
CSRF_COOKIE_HTTPONLY = False
CSRF_COOKIE_NAME = 'XSRF-TOKEN'
CSRF_HEADER_NAME = 'HTTP_X_XSRF_TOKEN'
AUTHENTICATION_BACKENDS = (
'social_core.backends.github.GithubOAuth2',
'social_core.backends.gitlab.GitLabOAuth2',
'social_core.backends.azuread.AzureADOAuth2',
'social_core.backends.microsoft.MicrosoftOAuth2',
'social_core.backends.google.GoogleOAuth2',
'django.contrib.auth.backends.ModelBackend',
)
SOCIAL_AUTH_GITHUB_SCOPE = ['read:user', 'user:email']
SOCIAL_AUTH_PIPELINE = (
'social_core.pipeline.social_auth.social_details',
'social_core.pipeline.social_auth.social_uid',
'social_core.pipeline.social_auth.auth_allowed',
'social_core.pipeline.social_auth.social_user',
'social_core.pipeline.user.get_username',
'social_core.pipeline.social_auth.associate_by_email',
'social_core.pipeline.user.create_user',
'social_core.pipeline.social_auth.associate_user',
'social_core.pipeline.social_auth.load_extra_data',
'social_core.pipeline.user.user_details',
)
APP_DIST_STORAGE = os.getenv('APP_DIST_STORAGE', 'file://' + str(BASE_DIR / 'app-dist'))
NPM_REGISTRY = os.getenv('NPM_REGISTRY', 'https://registry.npmjs.org').rstrip('/')
FRONTEND_URL = None
BACKEND_URL = None
GITHUB_ELIGIBLE_SPONSORSHIPS = None
for key in [
'FRONTEND_URL',
'BACKEND_URL',
'SOCIAL_AUTH_GITHUB_KEY',
'SOCIAL_AUTH_GITHUB_SECRET',
'SOCIAL_AUTH_GITLAB_KEY',
'SOCIAL_AUTH_GITLAB_SECRET',
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY',
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET',
'SOCIAL_AUTH_MICROSOFT_GRAPH_KEY',
'SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET',
'CONNECTION_GATEWAY_AUTH_CA',
'CONNECTION_GATEWAY_AUTH_CERTIFICATE',
'CONNECTION_GATEWAY_AUTH_KEY',
'GITHUB_ELIGIBLE_SPONSORSHIPS',
'GITHUB_SPONSORS_MIN_PAYMENT',
'ENABLE_LOGIN',
'GA_ID',
'GA_DOMAIN',
'ENABLE_HOMEPAGE',
]:
globals()[key] = os.getenv(key)
for key in [
'GITHUB_SPONSORS_MIN_PAYMENT',
]:
globals()[key] = int(globals()[key]) if globals()[key] else None
for key in [
'ENABLE_LOGIN',
'ENABLE_HOMEPAGE',
]:
globals()[key] = bool(globals()[key]) if globals()[key] else None
for key in [
'CONNECTION_GATEWAY_AUTH_CA',
'CONNECTION_GATEWAY_AUTH_CERTIFICATE',
'CONNECTION_GATEWAY_AUTH_KEY',
]:
v = globals()[key]
if v and not os.path.exists(v):
raise ValueError(f'{v} does not exist')
if GITHUB_ELIGIBLE_SPONSORSHIPS:
GITHUB_ELIGIBLE_SPONSORSHIPS = GITHUB_ELIGIBLE_SPONSORSHIPS.split(',')
else:
GITHUB_ELIGIBLE_SPONSORSHIPS = []
if FRONTEND_URL:
CORS_ALLOWED_ORIGINS = [FRONTEND_URL]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-xsrf-token',
'x-requested-with',
]
frontend_domain = urlparse(FRONTEND_URL).hostname
CSRF_TRUSTED_ORIGINS = [frontend_domain]
if BACKEND_URL:
CSRF_TRUSTED_ORIGINS.append(urlparse(BACKEND_URL).hostname)
SESSION_COOKIE_DOMAIN = frontend_domain
CSRF_COOKIE_DOMAIN = frontend_domain
FRONTEND_URL = FRONTEND_URL.rstrip('/')
if FRONTEND_URL.startswith('https://'):
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
else:
FRONTEND_URL = ''
LOGIN_REDIRECT_URL = FRONTEND_URL + '/app'

10
backend/tabby/urls.py Normal file
View File

@ -0,0 +1,10 @@
from django.contrib import admin
from django.urls import path, include
from .app.urls import urlpatterns as app_urlpatterns
urlpatterns = [
path('', include(app_urlpatterns)),
path('api/1/auth/social/', include('social_django.urls', namespace='social')),
path('admin/', admin.site.urls),
]

7
backend/tabby/wsgi.py Normal file
View File

@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tabby.settings')
application = get_wsgi_application()

21
docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
services:
frontend:
build: frontend
ports:
- 9090:80
environment:
- PORT=80
- BACKEND_URL=http://localhost:9091
backend:
build: backend
ports:
- 9091:80
volumes:
- ./app-dist:/app-dist
environment:
- DATABASE_URL
- PORT=80
- FRONTEND_URL=http://localhost:9090
- ENABLE_HOMEPAGE=False
- DEBUG=False
- APP_DIST_STORAGE=file:///app-dist

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

3
frontend/.dockerignore Normal file
View File

@ -0,0 +1,3 @@
build
build-server
node_modules

129
frontend/.eslintrc.yml Normal file
View File

@ -0,0 +1,129 @@
parser: '@typescript-eslint/parser'
parserOptions:
project:
- tsconfig.json
extends:
- 'plugin:@typescript-eslint/all'
plugins:
- '@typescript-eslint'
env:
browser: true
es6: true
node: true
commonjs: true
rules:
'@typescript-eslint/semi':
- error
- never
'@typescript-eslint/indent':
- error
- 2
'@typescript-eslint/explicit-member-accessibility':
- error
- accessibility: no-public
overrides:
parameterProperties: explicit
'@typescript-eslint/no-require-imports': off
'@typescript-eslint/no-parameter-properties': off
'@typescript-eslint/explicit-function-return-type': off
'@typescript-eslint/no-explicit-any': off
'@typescript-eslint/no-magic-numbers': off
'@typescript-eslint/member-delimiter-style': off
'@typescript-eslint/promise-function-async': off
'@typescript-eslint/require-array-sort-compare': off
'@typescript-eslint/no-floating-promises': off
'@typescript-eslint/prefer-readonly': off
'@typescript-eslint/require-await': off
'@typescript-eslint/strict-boolean-expressions': off
'@typescript-eslint/no-misused-promises':
- error
- checksVoidReturn: false
'@typescript-eslint/typedef': off
'@typescript-eslint/consistent-type-imports': off
'@typescript-eslint/sort-type-union-intersection-members': off
'@typescript-eslint/no-use-before-define':
- error
- classes: false
no-duplicate-imports: error
array-bracket-spacing:
- error
- never
block-scoped-var: error
brace-style: off
'@typescript-eslint/brace-style':
- error
- 1tbs
- allowSingleLine: true
computed-property-spacing:
- error
- never
comma-dangle: off
'@typescript-eslint/comma-dangle':
- error
- always-multiline
curly: error
eol-last: error
eqeqeq:
- error
- smart
max-depth:
- 1
- 5
max-statements:
- 1
- 80
no-multiple-empty-lines: error
no-mixed-spaces-and-tabs: error
no-trailing-spaces: error
'@typescript-eslint/no-unused-vars':
- error
- vars: all
args: after-used
argsIgnorePattern: ^_
no-undef: error
no-var: error
object-curly-spacing: off
'@typescript-eslint/object-curly-spacing':
- error
- always
quote-props:
- warn
- as-needed
- keywords: true
numbers: true
quotes: off
'@typescript-eslint/quotes':
- error
- single
- allowTemplateLiterals: true
'@typescript-eslint/no-confusing-void-expression':
- error
- ignoreArrowShorthand: true
'@typescript-eslint/no-non-null-assertion': off
'@typescript-eslint/no-unnecessary-condition':
- error
- allowConstantLoopConditions: true
'@typescript-eslint/restrict-template-expressions': off
'@typescript-eslint/prefer-readonly-parameter-types': off
'@typescript-eslint/no-unsafe-member-access': off
'@typescript-eslint/no-unsafe-call': off
'@typescript-eslint/no-unsafe-return': off
'@typescript-eslint/no-unsafe-assignment': off
'@typescript-eslint/naming-convention': off
'@typescript-eslint/lines-between-class-members':
- error
- exceptAfterSingleLine: true
'@typescript-eslint/dot-notation': off
'@typescript-eslint/no-implicit-any-catch': off
'@typescript-eslint/member-ordering': off
'@typescript-eslint/no-var-requires': off
'@typescript-eslint/no-unsafe-argument': off
'@typescript-eslint/restrict-plus-operands': off
'@typescript-eslint/space-infix-ops': off
'@typescript-eslint/explicit-module-boundary-types': off
overrides:
- files: '*.service.ts'
rules:
'@typescript-eslint/explicit-module-boundary-types':
- error

8
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules
*.ignore.js
*.ignore.js.map
build
build-server
*.d.ts
yarn-error.log
static

2
frontend/.pug-lintrc.js Normal file
View File

@ -0,0 +1,2 @@
module.export = {
}

20
frontend/Dockerfile Normal file
View File

@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
FROM node:12-alpine AS build
ARG BACKEND_URL
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn
COPY webpack* tsconfig.json ./
COPY assets assets
COPY src src
COPY theme theme
RUN yarn run build
RUN yarn run build:server
FROM node:12-alpine AS package
WORKDIR /app
COPY --from=0 /app/build build
COPY --from=0 /app/build-server build-server
COPY package.json .
CMD ["npm", "start"]

BIN
frontend/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

1
frontend/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 1024 1024" xml:space="preserve" style="enable-background:new 0 0 1024 1024"><style type="text/css">.st0{fill:url(#SVGID_1_)}.st1{opacity:.16;fill:url(#SVGID_2_)}.st2{fill:url(#SVGID_3_)}.st3{opacity:.16;fill:url(#SVGID_4_)}.st4{fill:url(#SVGID_5_)}.st5{opacity:.15;fill:url(#SVGID_6_)}.st6{fill:url(#SVGID_7_)}</style><g><linearGradient id="SVGID_1_" x1="260.967" x2="919.184" y1="871.181" y2="491.16" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#669abd"/><stop offset="1" style="stop-color:#77dbdb"/></linearGradient><polygon points="297.54 934.52 882.6 596.72 882.61 427.82 297.54 765.65" class="st0"/><linearGradient id="SVGID_2_" x1="553.505" x2="626.647" y1="617.828" y2="744.513" gradientUnits="userSpaceOnUse"><stop offset=".559" style="stop-color:#000;stop-opacity:0"/><stop offset="1" style="stop-color:#000"/></linearGradient><polygon points="297.54 934.52 882.6 596.72 882.61 427.82 297.54 765.65" class="st1"/></g><g><linearGradient id="SVGID_3_" x1="114.663" x2="334.091" y1="744.528" y2="871.214" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#6a8fad"/><stop offset="1" style="stop-color:#669abd"/></linearGradient><polygon points="151.23 681.18 151.22 850.09 297.54 934.52 297.54 765.65" class="st2"/><linearGradient id="SVGID_4_" x1="260.948" x2="187.806" y1="744.528" y2="871.213" gradientUnits="userSpaceOnUse"><stop offset=".559" style="stop-color:#000;stop-opacity:0"/><stop offset="1" style="stop-color:#000"/></linearGradient><polygon points="151.23 681.18 151.22 850.09 297.54 934.52 297.54 765.65" class="st3"/></g><g><linearGradient id="SVGID_5_" x1="114.663" x2="553.503" y1="237.793" y2="491.157" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#6a8fad"/><stop offset="1" style="stop-color:#669abd"/></linearGradient><polygon points="151.23 174.45 151.21 343.36 443.79 512.27 590.08 427.81" class="st4"/><linearGradient id="SVGID_6_" x1="370.656" x2="297.509" y1="301.128" y2="427.822" gradientUnits="userSpaceOnUse"><stop offset=".559" style="stop-color:#000;stop-opacity:0"/><stop offset="1" style="stop-color:#000"/></linearGradient><polygon points="151.23 174.45 151.21 343.36 443.79 512.27 590.08 427.81" class="st5"/></g><linearGradient id="SVGID_7_" x1="78.091" x2="736.337" y1="554.498" y2="174.459" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#ccecff"/><stop offset="1" style="stop-color:#9feced"/></linearGradient><polygon points="297.51 765.64 151.23 681.18 590.08 427.81 151.23 174.45 297.5 90 882.61 427.82" class="st6"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

12
frontend/cloudbuild.yaml Normal file
View File

@ -0,0 +1,12 @@
steps:
- name: 'gcr.io/cloud-builders/docker'
dir: 'frontend'
args:
- build
- '-t'
- '${_DOCKER_TAG}'
- '--cache-from'
- '${_DOCKER_TAG}'
- '.'
images: ['${_DOCKER_TAG}']

80
frontend/package.json Normal file
View File

@ -0,0 +1,80 @@
{
"name": "tabby-web",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"lint": "eslint src",
"build": "webpack --progress",
"watch": "DEV=1 webpack --progress --watch",
"build:server": "webpack --progress -c webpack.config.server.js",
"watch:server": "DEV=1 webpack --progress --watch -c webpack.config.server.js",
"start": "node build-server/server.js"
},
"private": true,
"devDependencies": {
"@angular/animations": "^12.2.11",
"@angular/cdk": "^12.2.11",
"@angular/common": "^12.2.11",
"@angular/compiler": "^12.2.11",
"@angular/compiler-cli": "^12.2.11",
"@angular/core": "^12.2.11",
"@angular/forms": "^12.2.11",
"@angular/platform-browser": "^12.2.11",
"@angular/platform-browser-dynamic": "^12.2.11",
"@angular/platform-server": "^12.2.11",
"@angular/router": "^12.2.11",
"@fontsource/fira-code": "^4.5.0",
"@fortawesome/angular-fontawesome": "0.8",
"@fortawesome/fontawesome-free": "^5.7.2",
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-brands-svg-icons": "^5.15.3",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@ng-bootstrap/ng-bootstrap": "11.0.0-beta.1",
"@ngtools/webpack": "^12.2.11",
"@nguniversal/express-engine": "^12.1.2",
"@tabby-gang/to-string-loader": "^1.1.7-beta.1",
"@types/node": "^11.9.5",
"@typescript-eslint/eslint-plugin": "^5.1.0",
"@typescript-eslint/parser": "^5.1.0",
"apply-loader": "^2.0.0",
"bootstrap": "^5.0.1",
"buffer": "^6.0.3",
"core-js": "^3.14.0",
"css-loader": "^2.1.0",
"deepmerge": "^4.2.2",
"domino": "^2.1.6",
"dotenv": "^10.0.0",
"eslint": "^7.31.0",
"express": "^4.17.1",
"file-loader": "^1.1.11",
"html-loader": "^2.1.2",
"html-webpack-plugin": "^5.3.2",
"js-yaml": "^4.1.0",
"mini-css-extract-plugin": "^2.1.0",
"ngx-image-zoom": "^0.6.0",
"ngx-toastr": "^14.0.0",
"node-sass": "^6.0.0",
"pug": "^3.0.2",
"pug-cli": "^1.0.0-alpha6",
"pug-html-loader": "^1.1.5",
"pug-loader": "^2.4.0",
"raw-loader": "^4.0.2",
"rxjs": "^7.1.0",
"sass-loader": "^11.1.1",
"script-loader": "^0.7.2",
"semver": "^7.3.5",
"source-code-pro": "^2.30.1",
"source-map-support": "^0.5.19",
"source-sans-pro": "^2.45.0",
"style-loader": "^0.23.1",
"three": "^0.119.0",
"throng": "^5.0.0",
"typescript": "~4.3.2",
"val-loader": "^4.0.0",
"vanta": "^0.5.21",
"webpack": "^5.59.1",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.9.1",
"zone.js": "^0.11.4"
}
}

49
frontend/src/api.ts Normal file
View File

@ -0,0 +1,49 @@
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Resolve } from '@angular/router'
export interface User {
id: number
active_config: number
active_version: string
custom_connection_gateway: string|null
custom_connection_gateway_token: string|null
config_sync_token: string
github_username: string
is_pro: boolean
is_sponsor: boolean
}
export interface Config {
id: number
content: string
last_used_with_version: string
created_at: Date
modified_at: Date
}
export interface Version {
version: string
plugins: string[]
}
export interface InstanceInfo {
login_enabled: boolean
homepage_enabled: boolean
}
export interface Gateway {
host: string
port: number
url: string
auth_token: string
}
@Injectable({ providedIn: 'root' })
export class InstanceInfoResolver implements Resolve<Promise<InstanceInfo>> {
constructor (private http: HttpClient) { }
resolve (): Promise<InstanceInfo> {
return this.http.get('/api/1/instance-info').toPromise() as Promise<InstanceInfo>
}
}

View File

@ -0,0 +1,8 @@
/* eslint-disable @typescript-eslint/no-extraneous-class */
import { Component } from '@angular/core'
@Component({
selector: 'app',
template: '<router-outlet></router-outlet>',
})
export class AppComponent { }

View File

@ -0,0 +1,53 @@
/* eslint-disable @typescript-eslint/no-extraneous-class */
import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { ClipboardModule } from '@angular/cdk/clipboard'
import { TransferHttpCacheModule } from '@nguniversal/common'
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'
import { HttpClientModule } from '@angular/common/http'
import { AppComponent } from './app.component'
import { CommonAppModule } from 'src/common'
import '@fortawesome/fontawesome-svg-core/styles.css'
const ROUTES = [
{
path: '',
loadChildren: () => import(/* webpackChunkName: "homepage" */'./homepage').then(m => m.HomepageModule),
},
{
path: 'app',
loadChildren: () => import(/* webpackChunkName: "app" */'./app').then(m => m.ApplicationModule),
},
{
path: 'login',
loadChildren: () => import(/* webpackChunkName: "login" */'./login').then(m => m.LoginModule),
},
]
@NgModule({
imports: [
BrowserModule.withServerTransition({
appId: 'tabby',
}),
CommonAppModule.forRoot(),
TransferHttpCacheModule,
BrowserAnimationsModule,
CommonModule,
FormsModule,
FontAwesomeModule,
ClipboardModule,
HttpClientModule,
RouterModule.forRoot(ROUTES),
],
declarations: [
AppComponent,
],
bootstrap: [AppComponent],
})
export class AppModule { }

View File

@ -0,0 +1,15 @@
/* eslint-disable @typescript-eslint/no-extraneous-class */
import { NgModule } from '@angular/core'
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server'
import { AppModule } from './app.module'
import { AppComponent } from './app.component'
@NgModule({
imports: [
AppModule,
ServerModule,
ServerTransferStateModule,
],
bootstrap: [AppComponent],
})
export class AppServerModule {}

View File

@ -0,0 +1,46 @@
.modal-header
h5.modal-title Config file
.modal-body
.header(*ngIf='configService.activeConfig')
.d-flex.align-items-center.py-2
.me-auto
label Active config
.title
fa-icon([icon]='_configIcon')
span.ms-2 {{configService.activeConfig.name}}
button.btn.btn-semi.me-2((click)='configService.duplicateActiveConfig()')
fa-icon([icon]='_copyIcon', [fixedWidth]='true')
button.btn.btn-semi((click)='deleteConfig()')
fa-icon([icon]='_deleteIcon', [fixedWidth]='true')
.d-flex.align-items-center.py-2(*ngIf='configService.activeVersion')
.me-auto App version:
div(ngbDropdown)
button.btn.btn-semi(ngbDropdownToggle) {{configService.activeVersion.version}}
div(ngbDropdownMenu)
button(
*ngFor='let version of configService.versions',
ngbDropdownItem,
[class.active]='version == configService.activeVersion',
(click)='selectVersion(version)'
) {{version.version}}
.pt-3(*ngIf='configService.configs.length > 1')
h5 Other configs
.list-group.list-group-light
ng-container(*ngFor='let config of configService.configs')
button.list-group-item.list-group-item-action(
*ngIf='config.id !== configService.activeConfig?.id',
(click)='selectConfig(config)'
)
fa-icon([icon]='_configIcon')
span {{config.name}}
.py-3
button.btn.btn-semi.w-100((click)='createNewConfig()')
fa-icon([icon]='_addIcon', [fixedWidth]='true')
span New config

View File

@ -0,0 +1,56 @@
import { Component } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { AppConnectorService } from '../services/appConnector.service'
import { ConfigService } from 'src/common'
import { faCopy, faFile, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'
import { Config, Version } from 'src/api'
@Component({
selector: 'config-modal',
templateUrl: './configModal.component.pug',
// styleUrls: ['./settingsModal.component.scss'],
})
export class ConfigModalComponent {
_addIcon = faPlus
_copyIcon = faCopy
_deleteIcon = faTrash
_configIcon = faFile
constructor (
private modalInstance: NgbActiveModal,
public appConnector: AppConnectorService,
public configService: ConfigService,
) {
}
cancel () {
this.modalInstance.dismiss()
}
async createNewConfig () {
const config = await this.configService.createNewConfig()
await this.configService.selectConfig(config)
this.modalInstance.dismiss()
}
async selectConfig (config: Config) {
await this.configService.selectConfig(config)
this.modalInstance.dismiss()
}
async selectVersion (version: Version) {
await this.configService.selectVersion(version)
this.modalInstance.dismiss()
}
async deleteConfig () {
if (!this.configService.activeConfig) {
return
}
if (confirm('Delete this config? This cannot be undone.')) {
await this.configService.deleteConfig(this.configService.activeConfig)
}
this.configService.selectDefaultConfig()
this.modalInstance.dismiss()
}
}

View File

@ -0,0 +1,8 @@
.list-group.list-group-light
.list-group-item.d-flex(*ngFor='let socket of appConnector.sockets')
fa-icon.text-success.me-2([icon]='_circleIcon', [fixedWidth]='true')
.me-auto
div {{socket.options.host}}:{{socket.options.port}}
.text-muted via {{socket.url}}
button.btn.btn-link((click)='closeSocket(socket)')
fa-icon([icon]='_closeIcon', [fixedWidth]='true')

View File

@ -0,0 +1,20 @@
import { Component } from '@angular/core'
import { AppConnectorService, SocketProxy } from '../services/appConnector.service'
import { faCircle, faTimes } from '@fortawesome/free-solid-svg-icons'
@Component({
selector: 'connection-list',
templateUrl: './connectionList.component.pug',
})
export class ConnectionListComponent {
_circleIcon = faCircle
_closeIcon = faTimes
constructor (
public appConnector: AppConnectorService,
) { }
closeSocket (socket: SocketProxy) {
socket.close(new Error('Connection closed by user'))
}
}

View File

@ -0,0 +1,36 @@
.sidebar
img.logo(src='{{_logo}}')
button.btn.mt-auto(
(click)='openConfig()',
title='Manage configs'
)
fa-icon([icon]='_configIcon', [fixedWidth]='true', size='lg')
button.btn(
(click)='openSettings()',
*ngIf='loginService.user',
title='Settings'
)
fa-icon([icon]='_settingsIcon', [fixedWidth]='true', size='lg')
a.btn.mt-3(
href='/login',
*ngIf='!loginService.user',
title='Log in'
)
fa-icon([icon]='_loginIcon', [fixedWidth]='true', size='lg')
button.btn.mt-3(
(click)='logout()',
*ngIf='loginService.user',
title='Log out'
)
fa-icon([icon]='_logoutIcon', [fixedWidth]='true', size='lg')
.terminal
iframe(#iframe, [hidden]='!showApp')
.alert.alert-warning.d-flex.border-0.m-0(*ngIf='showApp && !loginService.user')
fa-icon.me-2([icon]='_saveIcon', [fixedWidth]='true')
div
div To save profiles and settings, #[a(href='/login') log in].

View File

@ -0,0 +1,66 @@
@import "~theme/vars";
:host {
position: absolute;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
}
.sidebar {
width: 64px;
flex: none;
display: flex;
flex-direction: column;
align-items: stretch;
.logo {
width: 32px;
height: 32px;
align-self: center;
margin-top: 15px;
margin-bottom: 20px;
}
>.btn {
width: 64px;
height: 64px;
background: transparent;
box-shadow: none;
&::after {
display: none;
}
&:hover {
color: white;
}
}
}
.terminal {
flex: 1 1 0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
> * {
flex: none;
}
> iframe {
background: $body-bg;
border: none;
flex: 1 1 0;
}
}
.config-menu {
.header {
border-bottom: 1px solid black;
}
}

View File

@ -0,0 +1,104 @@
import { Component, ElementRef, ViewChild } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { Title } from '@angular/platform-browser'
import { AppConnectorService } from '../services/appConnector.service'
import { faCog, faFile, faPlus, faSave, faSignInAlt, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { SettingsModalComponent } from './settingsModal.component'
import { ConfigModalComponent } from './configModal.component'
import { ConfigService, LoginService } from 'src/common'
import { combineLatest } from 'rxjs'
import { Config, Version } from 'src/api'
@Component({
selector: 'main',
templateUrl: './main.component.pug',
styleUrls: ['./main.component.scss'],
})
export class MainComponent {
_logo = require('../../../assets/logo.svg')
_settingsIcon = faCog
_loginIcon = faSignInAlt
_logoutIcon = faSignOutAlt
_addIcon = faPlus
_configIcon = faFile
_saveIcon = faSave
showApp = false
@ViewChild('iframe') iframe: ElementRef
constructor (
titleService: Title,
public appConnector: AppConnectorService,
private http: HttpClient,
public loginService: LoginService,
private ngbModal: NgbModal,
private config: ConfigService,
) {
titleService.setTitle('Tabby')
window.addEventListener('message', this.connectorRequestHandler)
}
connectorRequestHandler = event => {
if (event.data === 'request-connector') {
this.iframe.nativeElement.contentWindow['__connector__'] = this.appConnector
this.iframe.nativeElement.contentWindow.postMessage('connector-ready', '*')
}
}
async ngAfterViewInit () {
await this.loginService.ready$.toPromise()
combineLatest(
this.config.activeConfig$,
this.config.activeVersion$
).subscribe(([config, version]) => {
this.reloadApp(config, version)
})
await this.config.ready$.toPromise()
await this.config.selectDefaultConfig()
}
ngOnDestroy () {
window.removeEventListener('message', this.connectorRequestHandler)
}
unloadApp () {
this.showApp = false
this.iframe.nativeElement.src = 'about:blank'
}
async loadApp (config, version) {
this.showApp = true
this.iframe.nativeElement.src = '/terminal'
if (this.loginService.user) {
await this.http.patch(`/api/1/configs/${config.id}`, {
last_used_with_version: version.version,
}).toPromise()
}
}
reloadApp (config: Config, version: Version) {
// TODO check config incompatibility
setTimeout(() => {
this.appConnector.setState(config, version)
this.loadApp(config, version)
})
}
async openConfig () {
await this.ngbModal.open(ConfigModalComponent).result
}
async openSettings () {
await this.ngbModal.open(SettingsModalComponent).result
}
async logout () {
await this.http.post('/api/1/auth/logout', null).toPromise()
location.href = '/'
}
}

View File

@ -0,0 +1,72 @@
.modal-header
h3.modal-title Settings
.modal-body
.mb-3
h5 GitHub account
a.btn.btn-info(href='{{commonService.backendURL}}/api/1/auth/social/login/github', *ngIf='!user.github_username')
fa-icon([icon]='_githubIcon', [fixedWidth]='true')
span Connect a GitHub account
.alert.alert-success.d-flex(*ngIf='user.github_username')
fa-icon.me-2([icon]='_okIcon', [fixedWidth]='true')
div
div Connected as #[strong {{user.github_username}}]
div(*ngIf='user.is_sponsor') Thank you for supporting Tabby on GitHub!
.mb-3.mt-4
h5 Config sync
.d-flex.aling-items-stretch.mb-3
.form-floating.w-100
input.form-control(
type='text',
readonly,
[ngModel]='user.config_sync_token'
)
label Sync token for the Tabby app
button.btn.btn-dark([cdkCopyToClipboard]='user.config_sync_token')
fa-icon([icon]='_copyIcon', [fixedWidth]='true')
.mb-3.mt-4
h5 Connection gateway
.form-check.form-switch
input.form-check-input(
type='checkbox',
[(ngModel)]='customGatewayEnabled'
)
label(class='form-check-label') Use custom connection gateway
small.text-muted This allows you to securely route connections through your own hosted gateway. See #[a(href='https://github.com/Eugeny/tabby-connection-gateway#readme', target='_blank') tabby-connection-gateway] for setup instructions.
form
input.d-none(type='text', name='fakeusername')
input.d-none(type='password', name='fakepassword')
.mb-3(*ngIf='customGatewayEnabled')
.form-floating
input.form-control(
type='text',
[(ngModel)]='user.custom_connection_gateway',
placeholder='wss://1.2.3.4',
autocomplete='off'
)
label Gateway address
.mb-3(*ngIf='customGatewayEnabled')
.form-floating
input.form-control(
type='password',
[(ngModel)]='user.custom_connection_gateway_token',
placeholder='123',
autocomplete='new-password'
)
label Gateway authentication token
.mb-3.mt-4(*ngIf='appConnector.sockets.length')
h5 Active connections
connection-list
.modal-footer
.text-muted Account ID: {{user.id}}
.ms-auto
button.btn.btn-primary((click)='apply()') Apply
button.btn.btn-secondary((click)='cancel()') Cancel

View File

@ -0,0 +1,42 @@
import { Component } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { User } from 'src/api'
import { CommonService, LoginService } from 'src/common'
import { AppConnectorService } from '../services/appConnector.service'
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons'
@Component({
selector: 'settings-modal',
templateUrl: './settingsModal.component.pug',
})
export class SettingsModalComponent {
user: User
customGatewayEnabled = false
_githubIcon = faGithub
_copyIcon = faCopy
_okIcon = faCheck
constructor (
public appConnector: AppConnectorService,
public commonService: CommonService,
private modalInstance: NgbActiveModal,
private loginService: LoginService,
) {
if (!loginService.user) {
return
}
this.user = { ...loginService.user }
this.customGatewayEnabled = !!this.user.custom_connection_gateway
}
async apply () {
Object.assign(this.loginService.user, this.user)
this.modalInstance.close()
await this.loginService.updateUser()
}
cancel () {
this.modalInstance.dismiss()
}
}

View File

@ -0,0 +1,28 @@
.modal-header
h1.modal-title Hey!
.modal-body
h4 It looks like you're enjoying Tabby a lot!
p Tabby Web has a limit of {{appConnector.connectionLimit}} simultaneous connections due to the fact that I have to pay for hosting and traffic out of my own pocket.
p #[strong You can have unlimited parallel connections] if you support Tabby on GitHub with #[code $3]/month or more. It's cancellable anytime, there are no hidden costs and it helps me pay my bills.
a.btn.btn-primary.btn-lg.d-block.mb-3(href='https://github.com/sponsors/Eugeny', target='_blank')
fa-icon.me-2([icon]='_loveIcon')
span Support Tabby on GitHub
button.btn.btn-warning.d-block.w-100((click)='skipOnce()', *ngIf='canSkip')
fa-icon.me-2([icon]='_giftIcon')
span Skip - just this one time
p.mt-3 If you work in education, have already supported me on Ko-fi before, or your country isn't supported on GitHub Sponsors, just #[a(href='mailto:e@ajenti.org?subject=Help with Tabby Pro') let me know] and I'll hook you up.
.mb-3(*ngIf='!loginService.user?.github_username')
a.btn.btn-info(href='{{commonService.backendURL}}/api/1/auth/social/login/github')
fa-icon([icon]='_githubIcon', [fixedWidth]='true')
span Connect your GitHub account to link your sponsorship
.mt-4
p You can also kill any active connection from the list below to free up a slot.
connection-list

View File

@ -0,0 +1,35 @@
import { Component } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { faGift, faHeart } from '@fortawesome/free-solid-svg-icons'
import { AppConnectorService } from '../services/appConnector.service'
import { CommonService, LoginService } from 'src/common'
import { User } from 'src/api'
@Component({
selector: 'upgrade-modal',
templateUrl: './upgradeModal.component.pug',
})
export class UpgradeModalComponent {
user: User
_githubIcon = faGithub
_loveIcon = faHeart
_giftIcon = faGift
canSkip = false
constructor (
public appConnector: AppConnectorService,
public commonService: CommonService,
public loginService: LoginService,
private modalInstance: NgbActiveModal,
) {
this.canSkip = !window.localStorage['upgrade-modal-skipped']
}
skipOnce () {
window.localStorage['upgrade-modal-skipped'] = true
window.sessionStorage['upgrade-skip-active'] = true
this.modalInstance.close(true)
}
}

48
frontend/src/app/index.ts Normal file
View File

@ -0,0 +1,48 @@
/* eslint-disable @typescript-eslint/no-extraneous-class */
import { NgModule } from '@angular/core'
import { NgbDropdownModule, NgbModalModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { ClipboardModule } from '@angular/cdk/clipboard'
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'
import { MainComponent } from './components/main.component'
import { ConfigModalComponent } from './components/configModal.component'
import { SettingsModalComponent } from './components/settingsModal.component'
import { ConnectionListComponent } from './components/connectionList.component'
import { UpgradeModalComponent } from './components/upgradeModal.component'
import { InstanceInfoResolver } from 'src/api'
import { CommonAppModule } from 'src/common'
const ROUTES = [
{
path: '',
component: MainComponent,
resolve: {
instanceInfo: InstanceInfoResolver,
},
},
]
@NgModule({
imports: [
CommonAppModule,
CommonModule,
FormsModule,
NgbDropdownModule,
NgbModalModule,
NgbTooltipModule,
ClipboardModule,
FontAwesomeModule,
RouterModule.forChild(ROUTES),
],
declarations: [
MainComponent,
ConfigModalComponent,
SettingsModalComponent,
ConnectionListComponent,
UpgradeModalComponent,
],
})
export class ApplicationModule { }

View File

@ -0,0 +1,233 @@
import { Buffer } from 'buffer'
import { Subject } from 'rxjs'
import { debounceTime } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable, Injector, NgZone } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { UpgradeModalComponent } from '../components/upgradeModal.component'
import { Config, Gateway, Version } from 'src/api'
import { LoginService, CommonService } from 'src/common'
export interface ServiceMessage {
_: string
[k: string]: any
}
export class SocketProxy {
connect$ = new Subject<void>()
data$ = new Subject<Uint8Array>()
error$ = new Subject<Error>()
close$ = new Subject<void>()
url: string
authToken: string
webSocket: WebSocket|null
initialBuffers: any[] = []
options: {
host: string
port: number
}
private appConnector: AppConnectorService
private loginService: LoginService
private ngbModal: NgbModal
private zone: NgZone
constructor (
injector: Injector,
) {
this.appConnector = injector.get(AppConnectorService)
this.loginService = injector.get(LoginService)
this.ngbModal = injector.get(NgbModal)
this.zone = injector.get(NgZone)
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async connect (options: any): Promise<void> {
if (!this.loginService.user?.is_pro && this.appConnector.sockets.length > this.appConnector.connectionLimit && !window.sessionStorage['upgrade-skip-active']) {
let skipped = false
try {
skipped = await this.zone.run(() => this.ngbModal.open(UpgradeModalComponent)).result
} catch { }
if (!skipped) {
this.close(new Error('Connection limit reached'))
return
}
}
this.options = options
if (this.loginService.user?.custom_connection_gateway) {
this.url = this.loginService.user.custom_connection_gateway
}
if (this.loginService.user?.custom_connection_gateway_token) {
this.authToken = this.loginService.user.custom_connection_gateway_token
}
if (!this.url) {
try {
const gateway = await this.appConnector.chooseConnectionGateway()
this.url = gateway.url
this.authToken = gateway.auth_token
} catch (err) {
this.close(err)
return
}
}
try {
this.webSocket = new WebSocket(this.url)
} catch (err) {
this.close(err)
return
}
this.webSocket.onerror = () => {
this.close(new Error(`Failed to connect to the connection gateway at ${this.url}`))
return
}
this.webSocket.onmessage = async event => {
if (typeof event.data === 'string') {
this.handleServiceMessage(JSON.parse(event.data))
} else {
this.data$.next(Buffer.from(await event.data.arrayBuffer()))
}
}
this.webSocket.onclose = () => {
this.close()
}
}
handleServiceMessage (msg: ServiceMessage): void {
if (msg._ === 'hello') {
this.sendServiceMessage({
_: 'hello',
version: 1,
auth_token: this.authToken,
})
} else if (msg._ === 'ready') {
this.sendServiceMessage({
_: 'connect',
host: this.options.host,
port: this.options.port,
})
} else if (msg._ === 'connected') {
this.connect$.next()
this.connect$.complete()
for (const b of this.initialBuffers) {
this.webSocket?.send(b)
}
this.initialBuffers = []
} else if (msg._ === 'error') {
console.error('Connection gateway error', msg)
this.close(new Error(msg.details))
} else {
console.warn('Unknown service message', msg)
}
}
sendServiceMessage (msg: ServiceMessage): void {
this.webSocket?.send(JSON.stringify(msg))
}
write (chunk: Buffer): void {
if (!this.webSocket?.readyState) {
this.initialBuffers.push(chunk)
} else {
this.webSocket.send(chunk)
}
}
close (error?: Error): void {
this.webSocket?.close()
if (error) {
this.error$.next(error)
}
this.connect$.complete()
this.data$.complete()
this.error$.complete()
this.close$.next()
this.close$.complete()
}
}
@Injectable({ providedIn: 'root' })
export class AppConnectorService {
private configUpdate = new Subject<string>()
private config: Config
private version: Version
connectionLimit = 3
sockets: SocketProxy[] = []
constructor (
private injector: Injector,
private http: HttpClient,
private commonService: CommonService,
private zone: NgZone,
private loginService: LoginService,
) {
this.configUpdate.pipe(debounceTime(1000)).subscribe(async content => {
if (this.loginService.user) {
const result = await this.http.patch(`/api/1/configs/${this.config.id}`, { content }).toPromise()
Object.assign(this.config, result)
}
})
}
setState (config: Config, version: Version): void {
this.config = config
this.version = version
}
async loadConfig (): Promise<string> {
return this.config.content
}
async saveConfig (content: string): Promise<void> {
this.configUpdate.next(content)
this.config.content = content
}
getAppVersion (): string {
return this.version.version
}
getDistURL (): string {
return this.commonService.backendURL + '/app-dist'
}
getPluginsToLoad (): string[] {
const loadOrder = [
'tabby-core',
'tabby-settings',
'tabby-terminal',
'tabby-ssh',
'tabby-community-color-schemes',
'tabby-web',
]
return [
...loadOrder.filter(x => this.version.plugins.includes(x)),
...this.version.plugins.filter(x => !loadOrder.includes(x)),
]
}
createSocket (): SocketProxy {
return this.zone.run(() => {
const socket = new SocketProxy(this.injector)
this.sockets.push(socket)
socket.close$.subscribe(() => {
this.sockets = this.sockets.filter(x => x !== socket)
})
return socket
})
}
async chooseConnectionGateway (): Promise<Gateway> {
try {
return await this.http.post('/api/1/gateways/choose', {}).toPromise() as Gateway
} catch (err){
if (err.status === 503) {
throw new Error('All connection gateways are unavailable right now')
}
throw err
}
}
}

View File

@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/no-extraneous-class */
import { ModuleWithProviders, NgModule } from '@angular/core'
import { HttpClientXsrfModule, HTTP_INTERCEPTORS } from '@angular/common/http'
import { BackendXsrfInterceptor, UniversalInterceptor } from './interceptor'
@NgModule({
imports: [
HttpClientXsrfModule,
],
})
export class CommonAppModule {
static forRoot (): ModuleWithProviders<CommonAppModule> {
return {
ngModule: CommonAppModule,
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: UniversalInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: BackendXsrfInterceptor, multi: true },
],
}
}
}
export { LoginService } from './services/login.service'
export { ConfigService } from './services/config.service'
export { CommonService } from './services/common.service'

View File

@ -0,0 +1,38 @@
import { Injectable } from '@angular/core'
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpXsrfTokenExtractor } from '@angular/common/http'
import { Observable } from 'rxjs'
import { CommonService } from './services/common.service'
@Injectable({ providedIn: 'root' })
export class UniversalInterceptor implements HttpInterceptor {
constructor (private commonService: CommonService) { }
intercept (request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (!request.url.startsWith('//') && request.url.startsWith('/')) {
const endpoint = request.url
request = request.clone({
url: `${this.commonService.backendURL}${endpoint}`,
withCredentials: true,
})
}
return next.handle(request)
}
}
@Injectable({ providedIn: 'root' })
export class BackendXsrfInterceptor implements HttpInterceptor {
constructor (
private commonService: CommonService,
private tokenExtractor: HttpXsrfTokenExtractor,
) { }
intercept (req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (this.commonService.backendURL && req.url.startsWith(this.commonService.backendURL)) {
const token = this.tokenExtractor.getToken()
if (token !== null) {
req = req.clone({ setHeaders: { 'X-XSRF-TOKEN': token } })
}
}
return next.handle(req)
}
}

View File

@ -0,0 +1,26 @@
import { Inject, Injectable, Optional } from '@angular/core'
@Injectable({ providedIn: 'root' })
export class CommonService {
backendURL: string
constructor (@Inject('BACKEND_URL') @Optional() ssrBackendURL: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const tag = document.querySelector('meta[property=x-tabby-web-backend-url]')! as HTMLMetaElement
if (ssrBackendURL) {
this.backendURL = ssrBackendURL
tag.content = ssrBackendURL
} else {
if (tag.content && !tag.content.startsWith('{{')) {
this.backendURL = tag.content
} else {
this.backendURL = ''
}
}
console.log(this.backendURL)
if (this.backendURL.endsWith('/')) {
this.backendURL = this.backendURL.slice(0, -1)
}
}
}

View File

@ -0,0 +1,122 @@
import * as semverCompare from 'semver/functions/compare-loose'
import { AsyncSubject, Subject } from 'rxjs'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Config, User, Version } from '../../api'
import { LoginService } from './login.service'
@Injectable({ providedIn: 'root' })
export class ConfigService {
activeConfig$ = new Subject<Config>()
activeVersion$ = new Subject<Version>()
user: User
configs: Config[] = []
versions: Version[] = []
ready$ = new AsyncSubject<void>()
get activeConfig (): Config | null { return this._activeConfig }
get activeVersion (): Version | null { return this._activeVersion }
private _activeConfig: Config|null = null
private _activeVersion: Version|null = null
constructor (
private http: HttpClient,
private loginService: LoginService,
) {
this.init()
}
async updateUser (): Promise<void> {
if (!this.loginService.user) {
return
}
await this.http.put('/api/1/user', this.user).toPromise()
}
async createNewConfig (): Promise<Config> {
const configData = {
content: '{}',
last_used_with_version: this._activeVersion?.version ?? this.getLatestStableVersion().version,
}
if (!this.loginService.user) {
const config = {
id: Date.now(),
name: `Temporary config at ${new Date()}`,
created_at: new Date(),
modified_at: new Date(),
...configData,
}
this.configs.push(config)
return config
}
const config = (await this.http.post('/api/1/configs', configData).toPromise()) as Config
this.configs.push(config)
return config
}
getLatestStableVersion (): Version {
return this.versions[0]
}
async duplicateActiveConfig (): Promise<void> {
let copy: any = { ...this._activeConfig, id: undefined }
if (this.loginService.user) {
copy = (await this.http.post('/api/1/configs', copy).toPromise()) as Config
}
this.configs.push(copy)
}
async selectVersion (version: Version): Promise<void> {
this._activeVersion = version
this.activeVersion$.next(version)
}
async selectConfig (config: Config): Promise<void> {
let matchingVersion = this.versions.find(x => x.version === config.last_used_with_version)
if (!matchingVersion) {
// TODO ask to upgrade
matchingVersion = this.versions[0]
}
this._activeConfig = config
this.activeConfig$.next(config)
this.selectVersion(matchingVersion)
if (this.loginService.user) {
this.loginService.user.active_config = config.id
await this.loginService.updateUser()
}
}
async selectDefaultConfig (): Promise<void> {
await this.ready$.toPromise()
await this.loginService.ready$.toPromise()
this.selectConfig(this.configs.find(c => c.id === this.loginService.user?.active_config) ?? this.configs[0])
}
async deleteConfig (config: Config): Promise<void> {
if (this.loginService.user) {
await this.http.delete(`/api/1/configs/${config.id}`).toPromise()
}
this.configs = this.configs.filter(x => x.id !== config.id)
}
private async init () {
await this.loginService.ready$.toPromise()
if (this.loginService.user) {
this.configs = (await this.http.get('/api/1/configs').toPromise()) as Config[]
}
this.versions = (await this.http.get('/api/1/versions').toPromise()) as Version[]
this.versions.sort((a, b) => -semverCompare(a.version, b.version))
if (!this.configs.length) {
await this.createNewConfig()
}
this.ready$.next()
this.ready$.complete()
}
}

View File

@ -0,0 +1,33 @@
import { AsyncSubject } from 'rxjs'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { User } from '../../api'
@Injectable({ providedIn: 'root' })
export class LoginService {
user: User | null
ready$ = new AsyncSubject<void>()
constructor (private http: HttpClient) {
this.init()
}
async updateUser (): Promise<void> {
if (!this.user) {
return
}
await this.http.put('/api/1/user', this.user).toPromise()
}
private async init () {
try {
this.user = (await this.http.get('/api/1/user').toPromise()) as User
} catch {
this.user = null
}
this.ready$.next()
this.ready$.complete()
}
}

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