This commit is contained in:
Eugene Pankov 2021-07-24 15:48:12 +02:00
parent 0b0d711a08
commit 3de04221c2
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
19 changed files with 238 additions and 49 deletions

View File

@ -10,6 +10,7 @@
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@angular/animations": "^11.0.0", "@angular/animations": "^11.0.0",
"@angular/cdk": "^12.1.3",
"@angular/common": "^11.0.0", "@angular/common": "^11.0.0",
"@angular/compiler": "^11.0.0", "@angular/compiler": "^11.0.0",
"@angular/compiler-cli": "^11.0.0", "@angular/compiler-cli": "^11.0.0",

View File

@ -8,6 +8,8 @@ export interface User {
active_version: string active_version: string
custom_connection_gateway: string|null custom_connection_gateway: string|null
custom_connection_gateway_token: string|null custom_connection_gateway_token: string|null
config_sync_token: string
github_username: string
is_pro: boolean is_pro: boolean
} }

View File

@ -6,6 +6,7 @@ import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http' import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http'
import { ClipboardModule } from '@angular/cdk/clipboard'
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome' import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'
import { AppComponent } from './components/app.component' import { AppComponent } from './components/app.component'
import { MainComponent } from './components/main.component' import { MainComponent } from './components/main.component'
@ -50,6 +51,7 @@ const ROUTES = [
NgbDropdownModule, NgbDropdownModule,
NgbModalModule, NgbModalModule,
FontAwesomeModule, FontAwesomeModule,
ClipboardModule,
RouterModule.forRoot(ROUTES), RouterModule.forRoot(ROUTES),
], ],
declarations: [ declarations: [

View File

@ -8,7 +8,7 @@
label Active config label Active config
.title .title
fa-icon([icon]='_configIcon') fa-icon([icon]='_configIcon')
span.ms-2 {{configService.activeConfig.created_at|date:"medium"}} span.ms-2 {{configService.activeConfig.name}}
button.btn.btn-semi.me-2((click)='configService.duplicateActiveConfig()') button.btn.btn-semi.me-2((click)='configService.duplicateActiveConfig()')
fa-icon([icon]='_copyIcon', [fixedWidth]='true') fa-icon([icon]='_copyIcon', [fixedWidth]='true')
@ -38,7 +38,7 @@
(click)='selectConfig(config)' (click)='selectConfig(config)'
) )
fa-icon([icon]='_configIcon') fa-icon([icon]='_configIcon')
span Config created at {{config.created_at|date:"medium"}} span {{config.name}}
.py-3 .py-3
button.btn.btn-semi.w-100((click)='createNewConfig()') button.btn.btn-semi.w-100((click)='createNewConfig()')

View File

@ -15,7 +15,10 @@ class DemoConnector {
async loadConfig (): Promise<string> { async loadConfig (): Promise<string> {
return `{ return `{
recoverTabs: false recoverTabs: false,
web: {
preventAccidentalTabClosure: false,
},
}` }`
} }

View File

@ -4,11 +4,29 @@
.modal-body .modal-body
.mb-3 .mb-3
h5 GitHub account h5 GitHub account
a.btn.btn-info(href='/api/1/auth/social/login/github') a.btn.btn-info(href='/api/1/auth/social/login/github', *ngIf='!user.github_username')
fa-icon([icon]='_githubIcon', [fixedWidth]='true') fa-icon([icon]='_githubIcon', [fixedWidth]='true')
span Connect a GitHub account 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 .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 h5 Connection gateway
.form-check.form-switch .form-check.form-switch
input.form-check-input( input.form-check-input(

View File

@ -5,6 +5,7 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { User } from '../api' import { User } from '../api'
import { AppConnectorService } from '../services/appConnector.service' import { AppConnectorService } from '../services/appConnector.service'
import { faGithub } from '@fortawesome/free-brands-svg-icons' import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons'
@Component({ @Component({
selector: 'settings-modal', selector: 'settings-modal',
@ -15,6 +16,8 @@ export class SettingsModalComponent {
user: User user: User
customGatewayEnabled = false customGatewayEnabled = false
_githubIcon = faGithub _githubIcon = faGithub
_copyIcon = faCopy
_okIcon = faCheck
constructor ( constructor (
public appConnector: AppConnectorService, public appConnector: AppConnectorService,

View File

@ -19,12 +19,12 @@
@import "~bootstrap/scss/button-group"; @import "~bootstrap/scss/button-group";
// @import "~bootstrap/scss/nav"; // @import "~bootstrap/scss/nav";
// @import "~bootstrap/scss/navbar"; // @import "~bootstrap/scss/navbar";
// @import "~bootstrap/scss/card"; @import "~bootstrap/scss/card";
// @import "~bootstrap/scss/accordion"; // @import "~bootstrap/scss/accordion";
// @import "~bootstrap/scss/breadcrum/b"; // @import "~bootstrap/scss/breadcrum/b";
// @import "~bootstrap/scss/pagination"; // @import "~bootstrap/scss/pagination";
@import "~bootstrap/scss/badge"; @import "~bootstrap/scss/badge";
// @import "~bootstrap/scss/alert"; @import "~bootstrap/scss/alert";
// @import "~bootstrap/scss/progress"; // @import "~bootstrap/scss/progress";
@import "~bootstrap/scss/list-group"; @import "~bootstrap/scss/list-group";
// @import "~bootstrap/scss/close"; // @import "~bootstrap/scss/close";

View File

@ -191,3 +191,7 @@ $modal-content-border-width: 1px;
$progress-bg: $table-bg; $progress-bg: $table-bg;
$progress-height: 3px; $progress-height: 3px;
$alert-bg-scale: 90%;
$alert-border-scale: 50%;
$alert-color-scale: 50%;

View File

@ -17,7 +17,7 @@ from social_django.models import UserSocialAuth
from typing import List from typing import List
from .consumers import GatewayAdminConnection from .consumers import GatewayAdminConnection
from .sponsors import get_sponsor_usernames from .sponsors import check_is_sponsor, check_is_sponsor_cached
from .models import Config, Gateway, User from .models import Config, Gateway, User
@ -45,6 +45,8 @@ class GatewaySerializer(ModelSerializer):
class ConfigSerializer(ModelSerializer): class ConfigSerializer(ModelSerializer):
name = fields.CharField(required=False)
class Meta: class Meta:
model = Config model = Config
read_only_fields = ('user', 'created_at', 'modified_at') read_only_fields = ('user', 'created_at', 'modified_at')
@ -100,6 +102,7 @@ class AppVersionViewSet(ListModelMixin, GenericViewSet):
class UserSerializer(ModelSerializer): class UserSerializer(ModelSerializer):
id = fields.IntegerField() id = fields.IntegerField()
is_pro = fields.SerializerMethodField() is_pro = fields.SerializerMethodField()
is_sponsor = fields.SerializerMethodField()
github_username = fields.SerializerMethodField() github_username = fields.SerializerMethodField()
class Meta: class Meta:
@ -110,16 +113,18 @@ class UserSerializer(ModelSerializer):
'active_config', 'active_config',
'custom_connection_gateway', 'custom_connection_gateway',
'custom_connection_gateway_token', 'custom_connection_gateway_token',
'config_sync_token',
'is_pro', 'is_pro',
'is_sponsor',
'github_username', 'github_username',
) )
read_only_fields = ('id', 'username') read_only_fields = ('id', 'username')
def get_is_pro(self, obj): def get_is_pro(self, obj):
username = self.get_github_username(obj) return check_is_sponsor_cached(obj) or obj.force_pro
if not username:
return False def get_is_sponsor(self, obj):
return username in get_sponsor_usernames() return check_is_sponsor_cached(obj)
def get_github_username(self, obj): def get_github_username(self, obj):
social_auth = UserSocialAuth.objects.filter(user=obj, provider='github').first() social_auth = UserSocialAuth.objects.filter(user=obj, provider='github').first()

View File

@ -32,8 +32,8 @@ class Migration(migrations.Migration):
('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')), ('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')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('active_version', models.CharField(max_length=32, null=True)), ('active_version', models.CharField(max_length=32, null=True)),
('custom_connection_gateway', models.CharField(max_length=255, 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)), ('custom_connection_gateway_token', models.CharField(max_length=255, null=True, blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)), ('modified_at', models.DateTimeField(auto_now=True)),
], ],

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

@ -1,26 +1,38 @@
import secrets
from datetime import date
from django.db import models from django.db import models
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
from django.db.models.signals import post_save
class Config(models.Model): class Config(models.Model):
user = models.ForeignKey('app.User', related_name='configs', on_delete=models.CASCADE) user = models.ForeignKey('app.User', related_name='configs', on_delete=models.CASCADE)
name = models.CharField(max_length=255)
content = models.TextField(default='{}') content = models.TextField(default='{}')
last_used_with_version = models.CharField(max_length=32, null=True) last_used_with_version = models.CharField(max_length=32, null=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=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): class User(AbstractUser):
active_config = models.ForeignKey(Config, null=True, on_delete=models.SET_NULL, related_name='+') active_config = models.ForeignKey(Config, null=True, on_delete=models.SET_NULL, related_name='+')
active_version = models.CharField(max_length=32, null=True) active_version = models.CharField(max_length=32, null=True)
custom_connection_gateway = models.CharField(max_length=255, null=True, blank=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) 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) created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=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): class Gateway(models.Model):
host = models.CharField(max_length=255) host = models.CharField(max_length=255)

View File

@ -2,25 +2,34 @@ from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from gql import Client, gql from gql import Client, gql
from gql.transport.requests import RequestsHTTPTransport from gql.transport.requests import RequestsHTTPTransport
from social_django.models import UserSocialAuth
from .models import User
GQL_ENDPOINT = 'https://api.github.com/graphql' GQL_ENDPOINT = 'https://api.github.com/graphql'
CACHE_KEY = 'cached-sponsors' CACHE_KEY = 'cached-sponsors:%s'
def fetch_sponsor_usernames(): 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( client = Client(
transport=RequestsHTTPTransport( transport=RequestsHTTPTransport(
url=GQL_ENDPOINT, url=GQL_ENDPOINT,
use_json=True, use_json=True,
headers={ headers={
'Authorization': f'Bearer {settings.GITHUB_TOKEN}', 'Authorization': f'Bearer {token}',
} }
) )
) )
result = []
after = None after = None
while True: while True:
@ -31,21 +40,17 @@ def fetch_sponsor_usernames():
query = ''' query = '''
query { query {
user (login: "eugeny") { user (login: "eugeny") {
sponsorshipsAsMaintainer(%s, includePrivate: true) { sponsorshipsAsSponsor(%s) {
pageInfo { pageInfo {
startCursor startCursor
hasNextPage hasNextPage
endCursor endCursor
} }
totalRecurringMonthlyPriceInDollars
nodes { nodes {
createdAt sponsorable {
tier { ... on Organization { login }
monthlyPriceInDollars ... on User { login }
}
sponsor{
... on User {
login
}
} }
} }
} }
@ -54,18 +59,22 @@ def fetch_sponsor_usernames():
''' % (params,) ''' % (params,)
response = client.execute(gql(query)) response = client.execute(gql(query))
after = response['user']['sponsorshipsAsMaintainer']['pageInfo']['endCursor'] info = response['user']['sponsorshipsAsSponsor']
nodes = response['user']['sponsorshipsAsMaintainer']['nodes'] after = info['pageInfo']['endCursor']
nodes = info['nodes']
if not len(nodes): if not len(nodes):
break break
for node in nodes: for node in nodes:
if node['tier']['monthlyPriceInDollars'] >= settings.GITHUB_SPONSORS_MIN_PAYMENT: if node['sponsorable']['login'].lower() not in settings.GITHUB_ELIGIBLE_SPONSORSHIPS:
result.append(node['sponsor']['login']) continue
if info['totalRecurringMonthlyPriceInDollars'] >= settings.GITHUB_SPONSORS_MIN_PAYMENT:
return True
return result return False
def get_sponsor_usernames(): def check_is_sponsor_cached(user: User) -> bool:
if not cache.get(CACHE_KEY): cache_key = CACHE_KEY % user.id
cache.set(CACHE_KEY, fetch_sponsor_usernames(), timeout=30) if not cache.get(cache_key):
return cache.get(CACHE_KEY) cache.set(cache_key, check_is_sponsor(user), timeout=30)
return cache.get(cache_key)

View File

@ -1,11 +1,44 @@
import logging import logging
from tabby.app.models import User
from django.conf import settings from django.conf import settings
from django.contrib.auth import hashers, logout, login
from pyga.requests import Tracker, Page, Session, Visitor from pyga.requests import Tracker, Page, Session, Visitor
class GAMiddleware: class BaseMiddleware:
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = 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: if settings.GA_ID:
self.tracker = Tracker(settings.GA_ID, settings.GA_DOMAIN) self.tracker = Tracker(settings.GA_ID, settings.GA_DOMAIN)

View File

@ -46,6 +46,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tabby.middleware.TokenMiddleware',
'tabby.middleware.GAMiddleware', 'tabby.middleware.GAMiddleware',
] ]
@ -179,6 +180,8 @@ LOGIN_REDIRECT_URL = '/app'
APP_DIST_PATH = Path(os.getenv('APP_DIST_PATH', BASE_DIR / 'app-dist')) APP_DIST_PATH = Path(os.getenv('APP_DIST_PATH', BASE_DIR / 'app-dist'))
NPM_REGISTRY = os.getenv('NPM_REGISTRY', 'https://registry.npmjs.org').rstrip('/') NPM_REGISTRY = os.getenv('NPM_REGISTRY', 'https://registry.npmjs.org').rstrip('/')
GITHUB_ELIGIBLE_SPONSORSHIPS = None
for key in [ for key in [
'SOCIAL_AUTH_GITHUB_KEY', 'SOCIAL_AUTH_GITHUB_KEY',
'SOCIAL_AUTH_GITHUB_SECRET', 'SOCIAL_AUTH_GITHUB_SECRET',
@ -191,7 +194,7 @@ for key in [
'CONNECTION_GATEWAY_AUTH_CA', 'CONNECTION_GATEWAY_AUTH_CA',
'CONNECTION_GATEWAY_AUTH_CERTIFICATE', 'CONNECTION_GATEWAY_AUTH_CERTIFICATE',
'CONNECTION_GATEWAY_AUTH_KEY', 'CONNECTION_GATEWAY_AUTH_KEY',
'GITHUB_SPONSORS_USER', 'GITHUB_ELIGIBLE_SPONSORSHIPS',
'GITHUB_SPONSORS_MIN_PAYMENT', 'GITHUB_SPONSORS_MIN_PAYMENT',
'GITHUB_TOKEN', 'GITHUB_TOKEN',
'ENABLE_LOGIN', 'ENABLE_LOGIN',
@ -221,3 +224,8 @@ for key in [
v = globals()[key] v = globals()[key]
if v and not os.path.exists(v): if v and not os.path.exists(v):
raise ValueError(f'{v} does not exist') raise ValueError(f'{v} does not exist')
if GITHUB_ELIGIBLE_SPONSORSHIPS:
GITHUB_ELIGIBLE_SPONSORSHIPS = GITHUB_ELIGIBLE_SPONSORSHIPS.split(',')
else:
GITHUB_ELIGIBLE_SPONSORSHIPS = []

View File

@ -9,6 +9,15 @@
dependencies: dependencies:
tslib "^2.0.0" tslib "^2.0.0"
"@angular/cdk@^12.1.3":
version "12.1.3"
resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-12.1.3.tgz#716d1a20040adf1b41335f9f7b8bf961758de5e6"
integrity sha512-uCWHk/PjddNJsdrmexasphWGbf4kYtYyhUCSd4HEBrIDjYz166MTVSr3FHgn/s8/tlVou7uTnaEZM+ILWoe2iQ==
dependencies:
tslib "^2.2.0"
optionalDependencies:
parse5 "^5.0.0"
"@angular/common@^11.0.0": "@angular/common@^11.0.0":
version "11.2.14" version "11.2.14"
resolved "https://registry.yarnpkg.com/@angular/common/-/common-11.2.14.tgz#52887277b0ae0438e584f9ae97b417ee51a694b5" resolved "https://registry.yarnpkg.com/@angular/common/-/common-11.2.14.tgz#52887277b0ae0438e584f9ae97b417ee51a694b5"
@ -3046,6 +3055,11 @@ parse-json@^2.2.0:
dependencies: dependencies:
error-ex "^1.2.0" error-ex "^1.2.0"
parse5@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
parse5@^6.0.1: parse5@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
@ -4271,7 +4285,7 @@ trim-newlines@^1.0.0:
dependencies: dependencies:
glob "^7.1.2" glob "^7.1.2"
tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0: tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==