diff --git a/backend/tabby/app/api.py b/backend/tabby/app/api.py index b4ced52..fb551d5 100644 --- a/backend/tabby/app/api.py +++ b/backend/tabby/app/api.py @@ -131,7 +131,7 @@ class UserSerializer(ModelSerializer): read_only_fields = ('id', 'username') def get_is_pro(self, obj): - return check_is_sponsor_cached(obj) or obj.force_pro + 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) diff --git a/backend/tabby/app/views.py b/backend/tabby/app/views.py index 9e5a4f8..d6ea4a7 100644 --- a/backend/tabby/app/views.py +++ b/backend/tabby/app/views.py @@ -8,6 +8,8 @@ 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)) diff --git a/backend/tabby/settings.py b/backend/tabby/settings.py index baa8704..a3e7d68 100644 --- a/backend/tabby/settings.py +++ b/backend/tabby/settings.py @@ -100,6 +100,12 @@ AUTH_PASSWORD_VALIDATORS = [ AUTH_USER_MODEL = 'app.User' +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + ) +} + # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ @@ -174,10 +180,12 @@ APP_DIST_STORAGE = os.getenv('APP_DIST_STORAGE', 'file://' + str(BASE_DIR / 'app 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', @@ -243,6 +251,8 @@ if FRONTEND_URL: ] 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 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 178e804..8834e15 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -4,6 +4,7 @@ import { Resolve } from '@angular/router' import { Observable } from 'rxjs' export interface User { + id: number active_config: number active_version: string custom_connection_gateway: string|null @@ -11,6 +12,7 @@ export interface User { config_sync_token: string github_username: string is_pro: boolean + is_sponsor: boolean } export interface Config { diff --git a/frontend/src/app.module.ts b/frontend/src/app.module.ts index b46e7d4..b23192b 100644 --- a/frontend/src/app.module.ts +++ b/frontend/src/app.module.ts @@ -18,6 +18,8 @@ import { ConfigModalComponent } from './components/configModal.component' import { SettingsModalComponent } from './components/settingsModal.component' import { HomeComponent } from './components/home.component' import { LoginComponent } from './components/login.component' +import { ConnectionListComponent } from './components/connectionList.component' +import { UpgradeModalComponent } from './components/upgradeModal.component' import { InstanceInfoResolver } from './api' import '@fortawesome/fontawesome-svg-core/styles.css' @@ -75,6 +77,8 @@ const ROUTES = [ LoginComponent, ConfigModalComponent, SettingsModalComponent, + ConnectionListComponent, + UpgradeModalComponent, ], bootstrap: [AppComponent], }) diff --git a/frontend/src/components/connectionList.component.pug b/frontend/src/components/connectionList.component.pug new file mode 100644 index 0000000..c590986 --- /dev/null +++ b/frontend/src/components/connectionList.component.pug @@ -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') diff --git a/frontend/src/components/connectionList.component.ts b/frontend/src/components/connectionList.component.ts new file mode 100644 index 0000000..f236d9f --- /dev/null +++ b/frontend/src/components/connectionList.component.ts @@ -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')) + } +} diff --git a/frontend/src/components/login.component.pug b/frontend/src/components/login.component.pug index 8ccce37..5329189 100644 --- a/frontend/src/components/login.component.pug +++ b/frontend/src/components/login.component.pug @@ -1,5 +1,4 @@ -main(*ngIf='ready && loggedIn') -.login-view(*ngIf='ready && !loggedIn') +.login-view(*ngIf='ready') .buttons a.btn( *ngFor='let provider of providers', diff --git a/frontend/src/components/settingsModal.component.pug b/frontend/src/components/settingsModal.component.pug index 1ec685c..a90b1c8 100644 --- a/frontend/src/components/settingsModal.component.pug +++ b/frontend/src/components/settingsModal.component.pug @@ -4,7 +4,7 @@ .modal-body .mb-3 h5 GitHub account - a.btn.btn-info(href='/api/1/auth/social/login/github', *ngIf='!user.github_username') + 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') @@ -61,13 +61,12 @@ ) label Gateway authentication token - div(*ngIf='appConnector.sockets.length') + .mb-3.mt-4(*ngIf='appConnector.sockets.length') h5 Active connections - .list-group.list-group-flush - .list-group-item(*ngFor='let socket of appConnector.sockets') - div {{socket.options.host}}:{{socket.options.port}} - .text-muted via {{socket.url}} + 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 diff --git a/frontend/src/components/settingsModal.component.ts b/frontend/src/components/settingsModal.component.ts index 6f344cc..c8bd136 100644 --- a/frontend/src/components/settingsModal.component.ts +++ b/frontend/src/components/settingsModal.component.ts @@ -4,6 +4,7 @@ import { LoginService } from '../services/login.service' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { User } from '../api' import { AppConnectorService } from '../services/appConnector.service' +import { CommonService } from '../services/common.service' import { faGithub } from '@fortawesome/free-brands-svg-icons' import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons' @@ -21,6 +22,7 @@ export class SettingsModalComponent { constructor ( public appConnector: AppConnectorService, + public commonService: CommonService, private modalInstance: NgbActiveModal, private loginService: LoginService, ) { diff --git a/frontend/src/components/upgradeModal.component.pug b/frontend/src/components/upgradeModal.component.pug new file mode 100644 index 0000000..d2c0b02 --- /dev/null +++ b/frontend/src/components/upgradeModal.component.pug @@ -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 diff --git a/frontend/src/components/upgradeModal.component.ts b/frontend/src/components/upgradeModal.component.ts new file mode 100644 index 0000000..9f7d612 --- /dev/null +++ b/frontend/src/components/upgradeModal.component.ts @@ -0,0 +1,36 @@ +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 { LoginService } from '../services/login.service' +import { AppConnectorService } from '../services/appConnector.service' +import { CommonService } from '../services/common.service' +import { User } from '../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) + } +} diff --git a/frontend/src/services/appConnector.service.ts b/frontend/src/services/appConnector.service.ts index 10f5918..fb5d608 100644 --- a/frontend/src/services/appConnector.service.ts +++ b/frontend/src/services/appConnector.service.ts @@ -2,7 +2,9 @@ import { Buffer } from 'buffer' import { Subject } from 'rxjs' import { debounceTime } from 'rxjs/operators' import { HttpClient } from '@angular/common/http' -import { Injectable, Injector } from '@angular/core' +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 '../api' import { LoginService } from './login.service' import { CommonService } from './common.service' @@ -24,16 +26,31 @@ export class SocketProxy { 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 { + 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 this.url = this.loginService.user.custom_connection_gateway this.authToken = this.loginService.user.custom_connection_gateway_token @@ -127,12 +144,14 @@ export class AppConnectorService { private configUpdate = new Subject() private config: Config private version: Version + connectionLimit = 3 sockets: SocketProxy[] = [] constructor ( private injector: Injector, private http: HttpClient, private commonService: CommonService, + private zone: NgZone, ) { this.configUpdate.pipe(debounceTime(1000)).subscribe(async content => { @@ -180,12 +199,14 @@ export class AppConnectorService { } createSocket () { - const socket = new SocketProxy(this.injector) - this.sockets.push(socket) - socket.close$.subscribe(() => { - this.sockets = this.sockets.filter(x => x !== socket) + 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 }) - return socket } async chooseConnectionGateway (): Promise {