diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5578f61..ba427b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: docker login -u publisher -p ${DOCKER_TOKEN} ghcr.io # Build all containers - QUTEX_VERSION=$(echo ${GITHUB_REF##*/}) docker compose -f docker-compose.build.yml build - QUTEX_VERSION=$(echo ${GITHUB_REF##*/}) docker compose -f docker-compose.build.yml push + QUTEX_VERSION=$(echo ${GITHUB_REF##*/}) docker compose -f docker-compose.yml -f docker-compose.build.yml build + QUTEX_VERSION=$(echo ${GITHUB_REF##*/}) docker compose -f docker-compose.yml -f docker-compose.build.yml push env: DOCKER_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 32a5f65..cefd8b8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ docs/.sass-cache docs/.jekyll-cache docs/.jekyll-metadata docs/vendor + +# App stuff +services/ui/public/env.js \ No newline at end of file diff --git a/.local.env b/.local.env index d158134..2045ce7 100644 --- a/.local.env +++ b/.local.env @@ -3,6 +3,7 @@ AUTH_SERVICE_HOST=http://auth:4000 FLASK_ENV=development FQDN=http://localhost NODE_ENV=development +SUPER_ADMINS=["Y2lzY29zcGFyazovL3VzL1BFT1BMRS9kODRkZjI1MS1iYmY3LTRlZTEtOTM1OS00Y2I0MGIyOTBhN2I"] # UI DANGEROUSLY_DISABLE_HOST_CHECK=true diff --git a/.production.env b/.production.env index f814738..605bf06 100644 --- a/.production.env +++ b/.production.env @@ -12,4 +12,4 @@ MONGO_INITDB_DATABASE=qutex MONGO_INITDB_ROOT_PASSWORD_FILE=/run/secrets/mongoPassword # FEATURE FLAGS -BOT_2_0_0=false \ No newline at end of file +BOT_2_0_0=true \ No newline at end of file diff --git a/Dockerfile.mongoexpress b/Dockerfile.mongoexpress new file mode 100644 index 0000000..721faec --- /dev/null +++ b/Dockerfile.mongoexpress @@ -0,0 +1,3 @@ +FROM mongo-express + +RUN sed -i 's/req.adminDb = mongo.adminDb;/req.adminDb = mongo.mainClient.adminDb || undefined;/g' lib/router.js \ No newline at end of file diff --git a/Makefile b/Makefile index effc9d5..717b689 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ .PHONY: build $(SERVICE) build: - docker compose -f docker-compose.yml -f docker-compose.dev.yml build $(SERVICE) + docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.build.yml build $(SERVICE) -.PHONY: up +.PHONY: up $(SERVICE) up: - docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d + docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.build.yml up --build $(SERVICE) -d .PHONY: deploy $(VERSION) deploy: @@ -18,15 +18,24 @@ logs: down: docker compose down -.PHONY: test -test: +.PHONY: test-qutex +test-qutex: export QUTEX_TESTING=true && \ yarn --cwd services/bot test --verbose && \ unset QUTEX_TESTING +.PHONY: test-nginx +test-nginx: + pytest services/nginx/tests + +.PHONY: test +test: + ${MAKE} test-qutex + ${MAKE} test-nginx + .PHONY: lint lint: yarn --cwd services/bot lint yarn --cwd services/ui lint - docker run -it -v $(PWD)services:/apps/services alpine/flake8 /apps + flake8 services \ No newline at end of file diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 1766bae..c0b476c 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -1,35 +1,36 @@ version: '3.9' services: nginx: - image: ghcr.io/amthorn/qutex/qutex_nginx:${QUTEX_VERSION:-latest} build: context: services/nginx bot: - image: ghcr.io/amthorn/qutex/qutex_bot:${QUTEX_VERSION:-latest} build: context: ./services/bot ui: - image: ghcr.io/amthorn/qutex/qutex_ui:${QUTEX_VERSION:-latest} build: context: services/ui/ projects: - image: ghcr.io/amthorn/qutex/qutex_projects:${QUTEX_VERSION:-latest} build: context: . dockerfile: services/_api_service_template/Dockerfile args: SERVICE_PREFIX: projects users: - image: ghcr.io/amthorn/qutex/qutex_users:${QUTEX_VERSION:-latest} build: context: . dockerfile: services/_api_service_template/Dockerfile args: SERVICE_PREFIX: users auth: - image: ghcr.io/amthorn/qutex/qutex_auth:${QUTEX_VERSION:-latest} build: context: . dockerfile: services/_api_service_template/Dockerfile args: - SERVICE_PREFIX: auth \ No newline at end of file + SERVICE_PREFIX: auth + mongo_ui: + build: + context: . + dockerfile: Dockerfile.mongoexpress + mongo_backup: + build: + context: services/mongo_backup \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 152fa65..422f73a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,13 +8,13 @@ x-interactive: &interactive services: nginx: image: qutex_nginx:latest - build: - context: services/nginx environment: - CERTBOT_EMAIL: avatheavian@gmail.com - STAGING: "1" - DEBUG: "1" - RENEWAL_INTERVAL: 8d + STAGING: 1 + # DEBUG: 1 + volumes: + - ./services/nginx/dev_default.conf:/etc/nginx/user_conf.d/default.conf + ports: + - 80:80 bot: <<: *interactive image: qutex_bot:latest @@ -79,23 +79,14 @@ services: build: context: ./services/bot env_file: *env_files + volumes: + - ./services/bot:/app + mongo_backup: + image: qutex_mongo_backup:latest mongo: env_file: *env_files ports: - 27017:27017 - #################### - ## -- DEV ONLY -- ## ( FOR NOW ) - #################### - mongo_ui: - image: mongo-express:latest - environment: - ME_CONFIG_MONGODB_SERVER: mongo - ME_CONFIG_MONGODB_ADMINUSERNAME: root - ME_CONFIG_MONGODB_ADMINPASSWORD_FILE: /run/secrets/mongoPassword - ports: - - 8081:8081 - secrets: - - mongoPassword volumes: # ignore all css from the docker container and do not mount to my local dir # This is because the CSS files shouldn't be modified. Only the sass files diff --git a/docker-compose.yml b/docker-compose.yml index ca04a99..ea609ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -95,6 +95,43 @@ services: - mongo_volume:/data/db secrets: - mongoPassword + mongo_ui: + image: mongo-express:1.0.0-alpha + depends_on: + - mongo + environment: + ME_CONFIG_MONGODB_SERVER: mongo + ME_CONFIG_MONGODB_ADMINUSERNAME: root + ME_CONFIG_MONGODB_ADMINPASSWORD_FILE: /run/secrets/mongoPassword + ME_CONFIG_SITE_BASEURL: /admin/mongo/ + ME_CONFIG_OPTIONS_NO_DELETE: "true" + ME_CONFIG_OPTIONS_READONLY: "true" + ME_CONFIG_MONGODB_ENABLE_ADMIN: "true" + secrets: + - mongoPassword + mongo_backup: + image: ghcr.io/amthorn/qutex/qutex_mongo_backup:${QUTEX_VERSION:-latest} + environment: + MONGODB_HOST: mongo + MONGODB_PORT: 27017 + MONGODB_USER: root + MONGODB_PASS_FILE: /run/secrets/mongoPassword + CRON_TIME: 0 0 * * * + EXTRA_OPTS: --authenticationDatabase admin --db qutex --gzip --forceTableScan + MAX_BACKUPS: 14 + INIT_BACKUP: 1 + volumes: + - ./mongo_backups:/backup + secrets: + - mongoPassword + redis_ui: + image: rediscommander/redis-commander + environment: + REDIS_HOST: redis + REDIS_PORT: "6379" + URL_PREFIX: /admin/redis + ports: + - 8081:8081 secrets: token: file: secrets/prod/token diff --git a/package.json b/package.json index 4a4e6c4..73195e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "dependencies": { "eslint": "^7.32.0", - "qutex_web": "file:services/ui" + "qutex_web": "file:services/ui", + "react-dotenv": "^0.1.3" } } diff --git a/services/_api_service_template/Dockerfile b/services/_api_service_template/Dockerfile index 2ab5dce..350ed7a 100644 --- a/services/_api_service_template/Dockerfile +++ b/services/_api_service_template/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-alpine +FROM python:3.10-alpine LABEL MAINTAINER="Ava Thorn" EMAIL="avatheavian@gmail.com" ARG SERVICE_PREFIX diff --git a/services/_api_service_template/src/main.py b/services/_api_service_template/src/main.py index 0124104..8e0748a 100644 --- a/services/_api_service_template/src/main.py +++ b/services/_api_service_template/src/main.py @@ -13,6 +13,7 @@ app.config['SERVICE_PREFIX'] = os.environ.get('SERVICE_PREFIX') app.config['AUTH_SERVICE_TOKEN_CHECK_ROUTE'] = os.environ['AUTH_SERVICE_TOKEN_CHECK_ROUTE'] app.config['AUTH_SERVICE_HOST'] = os.environ['AUTH_SERVICE_HOST'] +app.config['SUPER_ADMINS'] = json.loads(os.environ['SUPER_ADMINS']) app.config['TOKEN_COOKIE_NAME'] = 'qutexToken' app.config['FQDN'] = os.environ.get('FQDN', 'http://localhost') app.config['DEFAULT_PAGE_LENGTH'] = 50 @@ -22,11 +23,12 @@ class CustomJSONEncoder(flask.json.JSONEncoder): - def default(self, o): + def default(self, o: object) -> str: if isinstance(o, ObjectId): return str(o) return super().default(o) + app.config["RESTX_JSON"] = {"cls": CustomJSONEncoder} ######################## @@ -35,34 +37,33 @@ def default(self, o): try: with open('/run/secrets/privateKey') as f: app.secret_key = f.read() -except Exception as e: +except Exception: print("WARNING: No privateKey secret provided for Flask application") try: with open('/run/secrets/token') as f: app.config['WEBEX_TEAMS_ACCESS_TOKEN'] = f.read().strip() -except Exception as e: +except Exception: print("WARNING: No webex teams access token provided for Flask application") try: with open('/run/secrets/mongoPassword') as f: app.config['MONGO_PASSWORD'] = f.read() -except Exception as e: +except Exception: print("WARNING: No mongo password provided for Flask application") ########## # MODELS # ########## -import setup_db +import setup_db # noqa ########## # APIS # ########## from setup_api import v1 # noqa - -import documents -import api +import documents # noqa +import api # noqa @v1.errorhandler(Exception) @@ -73,16 +74,24 @@ def handle_exception(e: Exception) -> flask.Response: return dump_data(e, flask.make_response(), e.messages, 422) elif isinstance(e, werkzeug.exceptions.Unauthorized): response = dump_data(e, e.get_response(), {e.name: e.description}, e.code) - return response[0], response[1], {**response[2], 'Set-Cookie': f'{app.config.get("TOKEN_COOKIE_NAME")}=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'} + return response[0], response[1], { + **response[2], + 'Set-Cookie': app.config.get("TOKEN_COOKIE_NAME") + + '=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' + } elif isinstance(e, werkzeug.exceptions.HTTPException): return dump_data(e, e.get_response(), {e.name: e.description}, e.code) return dump_data(e, flask.make_response(), {str(e.__class__.__name__): str(e)}, 500) @app.before_request -def authenticate(): +def authenticate() -> None: # if its not an unauthenticated route - if not flask.request.path.startswith('/api/v1/auth/') and not flask.request.path.endswith('/healthcheck'): + unauthenticated = [ + '/api/v1/auth/', + 'healthcheck' + ] + if not any([flask.request.path.startswith(i) for i in unauthenticated]): # Will throw 401 if not authenticated result = requests.get( f"{app.config['AUTH_SERVICE_HOST']}/api/v1/auth/token/check", diff --git a/services/_api_service_template/src/setup_db.py b/services/_api_service_template/src/setup_db.py index 98530c1..c1b3609 100644 --- a/services/_api_service_template/src/setup_db.py +++ b/services/_api_service_template/src/setup_db.py @@ -1,5 +1,3 @@ -import json - from app import app from flask_mongoengine import MongoEngine @@ -10,10 +8,6 @@ db = MongoEngine(app) app.db = db -class SerializerMixin: - def as_dict(self): - ugly = json.loads(self.to_json()) - class TimestampMixin(): created_at = db.DateTimeField(auto_now_add=True, auto_now=False) @@ -21,5 +15,5 @@ class TimestampMixin(): deleted_at = db.DateTimeField(required=False) -class BaseMixin(TimestampMixin, SerializerMixin): +class BaseMixin(TimestampMixin): id = db.StringField(primary_key=True) diff --git a/services/auth/api/__init__.py b/services/auth/api/__init__.py index 7135aa8..2254c41 100644 --- a/services/auth/api/__init__.py +++ b/services/auth/api/__init__.py @@ -1 +1 @@ -from api import v1 +from api import v1 # noqa diff --git a/services/auth/api/v1/__init__.py b/services/auth/api/v1/__init__.py index 08d573f..42c0d43 100644 --- a/services/auth/api/v1/__init__.py +++ b/services/auth/api/v1/__init__.py @@ -1,2 +1,2 @@ -from api.v1 import auth -from api.v1 import token \ No newline at end of file +from api.v1 import auth # noqa +from api.v1 import token # noqa diff --git a/services/auth/api/v1/auth/__init__.py b/services/auth/api/v1/auth/__init__.py index 9c33061..2b833d4 100644 --- a/services/auth/api/v1/auth/__init__.py +++ b/services/auth/api/v1/auth/__init__.py @@ -1,3 +1,3 @@ -from api.v1.auth import login -from api.v1.auth import logout -from api.v1.auth import register \ No newline at end of file +from api.v1.auth import login # noqa +from api.v1.auth import logout # noqa +from api.v1.auth import register # noqa diff --git a/services/auth/api/v1/auth/login.py b/services/auth/api/v1/auth/login.py index 452d50a..b508a2c 100644 --- a/services/auth/api/v1/auth/login.py +++ b/services/auth/api/v1/auth/login.py @@ -1,14 +1,12 @@ from app import app from setup_api import v1 -from blacklist_handler import BlacklistHandler from encoder import JWTEncoder from flask import jsonify, request -from flask_restx import Api, Resource +from flask_restx import Resource from marshmallow import Schema, fields from documents.person import PersonDocument from werkzeug.exceptions import Unauthorized -from typing import Union class LoginSchema(Schema): @@ -25,6 +23,7 @@ def post(self) -> dict[str, dict[str, str]]: if not user or user.passwordHash != PersonDocument._hash(data['password']): raise Unauthorized('Username or password incorrect') else: + # TODO: roles? token = JWTEncoder().encode(userId=user.id) response = jsonify({ 'data': {'token': token}, diff --git a/services/auth/api/v1/auth/register.py b/services/auth/api/v1/auth/register.py index b780bb3..b545951 100644 --- a/services/auth/api/v1/auth/register.py +++ b/services/auth/api/v1/auth/register.py @@ -23,23 +23,27 @@ class AuthRegisterApi(Resource): def post(self) -> dict[str, Union[list[dict], dict[str, str]]]: # Check if the token is valid and not expired # Also consume the token as user will have sent a password in as well - # And now we will set the password which can be used for further authentication in the future. + # And now we will set the password which can be used for + # further authentication in the future. # No need for temporary token anymore. data = RegisterSchema().load(request.json) # Will raise an error if not authzed temp_token = JWTEncoder().decode(data['code']) if 'email' not in temp_token: - # Should never occur unless something weird is going on where they are using a permanant JWT + # Should never occur unless something weird is going on + # where they are using a permanant JWT # as a temporary code or somehow have access to the secret key. raise Unauthorized("Token form is invalid.") bot = webexteamssdk.WebexTeamsAPI(app.config['WEBEX_TEAMS_ACCESS_TOKEN']) - + possible_users = [i for i in bot.people.list(email=temp_token['email'])] if len(possible_users) > 1: - raise Conflict("More than one webex account found for that email address. Cannot resolve conflict.") + raise Conflict( + "More than one webex account found for that email address. Cannot resolve conflict." + ) elif len(possible_users) == 0: # This shouldn't happen because how would a temporary code be sent otherwise? # Perhaps if they delete their account in the intermediary time. @@ -48,7 +52,8 @@ def post(self) -> dict[str, Union[list[dict], dict[str, str]]]: user_data = possible_users[0] # Authenticated, attempt to set password before blacklisting temporary token - # The basic PersonDocument.objects.get(id=user_data.id) does not work. Perhaps a bug in mongo engine + # The basic PersonDocument.objects.get(id=user_data.id) does not work. + # Perhaps a bug in mongo engine user = PersonDocument.objects(__raw__={'id': user_data.id}) user.update_one( set__displayName=user_data.displayName, @@ -72,4 +77,4 @@ def post(self) -> dict[str, Union[list[dict], dict[str, str]]]: 'text': f'Verified user "{user.email}" Successfully!!', 'priority': 'success' } - } \ No newline at end of file + } diff --git a/services/auth/api/v1/token/__init__.py b/services/auth/api/v1/token/__init__.py index b80d3a8..64d1dde 100644 --- a/services/auth/api/v1/token/__init__.py +++ b/services/auth/api/v1/token/__init__.py @@ -1,3 +1,3 @@ -from api.v1.token import check -from api.v1.token import generate -from api.v1.token import invalidate \ No newline at end of file +from api.v1.token import check # noqa +from api.v1.token import generate # noqa +from api.v1.token import invalidate # noqa \ No newline at end of file diff --git a/services/auth/api/v1/token/check.py b/services/auth/api/v1/token/check.py index 2bc02d2..d7c3922 100644 --- a/services/auth/api/v1/token/check.py +++ b/services/auth/api/v1/token/check.py @@ -4,11 +4,21 @@ from encoder import JWTEncoder from flask import request from flask_restx import Resource +from werkzeug.exceptions import Forbidden @v1.route('/token/check') class AuthCheckApi(Resource): def get(self) -> dict[str, dict[str, bool]]: # Will raise an error if not authzed - return {'data': {'success': True}, '_token': JWTEncoder().decode(request.cookies.get(app.config.get('TOKEN_COOKIE_NAME')))} + token = JWTEncoder().decode(request.cookies.get(app.config.get('TOKEN_COOKIE_NAME'))) + role = request.args.get('role') + # Only support super admin for now + # TODO: clean this up and make more parameterizable and better in general + if role and role == 'superadmin' and token['userId'] not in app.config['SUPER_ADMINS']: + raise Forbidden + return { + 'data': {'success': True}, + 'token': token + } diff --git a/services/auth/api/v1/token/generate.py b/services/auth/api/v1/token/generate.py index f0141c6..9b3288f 100644 --- a/services/auth/api/v1/token/generate.py +++ b/services/auth/api/v1/token/generate.py @@ -17,8 +17,7 @@ def post(self) -> dict[str, dict[str, bool]]: if 'email' not in request.json: raise BadRequest("'email' key is required.") - bot = webexteamssdk.WebexTeamsAPI( - app.config['WEBEX_TEAMS_ACCESS_TOKEN']) + bot = webexteamssdk.WebexTeamsAPI(app.config['WEBEX_TEAMS_ACCESS_TOKEN']) # Generate the message so that we can use the ID to encode in the JWT token try: @@ -29,11 +28,13 @@ def post(self) -> dict[str, dict[str, bool]]: except webexteamssdk.exceptions.ApiError as e: if e.status_code == 400: raise BadRequest( - f'A Webex Account with email "{request.json["email"]}" does not exist.') + f'A Webex Account with email "{request.json["email"]}" does not exist.' + ) # Edit message so that the message ID can be encoded in the JWT # This is so that the message in teams can be deleted when it's consumed. - # That way, althought the token expires in 10 minutes, the deletion of the message helps to regulate it's only + # That way, althought the token expires in 10 minutes, + # the deletion of the message helps to regulate it's only # consumed once. Though this is not an assumption we can depend on in any strict sense. # SDK does not have "edit message" feature, just hit endpoint directly. @@ -41,12 +42,14 @@ def post(self) -> dict[str, dict[str, bool]]: 'email': request.json['email'], 'messageId': message.id }) - + # TODO: better way to manage all these hardcoded routes? + invalidate_token_route = '/api/v1/auth/token/invalidate' bot._session.put(f'/messages/{message.id}', json={ - 'markdown': f'Your temporary token is shown below. This token will expire in {self.expiration} minutes:' + + 'markdown': 'Your temporary token is shown below. ' + f'This token will expire in {self.expiration} minutes:' # TODO: should delete message and invalidate (blacklist) token - f'\n\n{token}\n\nWas this not generated by you? ' + - f'[Click Here]({app.config["FQDN"]}/api/v1/auth/token/invalidate?token={token})', + f'\n\n{token}\n\nWas this not generated by you? ' + f'[Click Here]({app.config["FQDN"]}{invalidate_token_route}?token={token})', 'roomId': message.roomId }) diff --git a/services/auth/api/v1/token/invalidate.py b/services/auth/api/v1/token/invalidate.py index 02da3e1..3dd8f5d 100644 --- a/services/auth/api/v1/token/invalidate.py +++ b/services/auth/api/v1/token/invalidate.py @@ -13,7 +13,7 @@ class TokenInvalidateApi(Resource): # Is a get request so users can click link from webex teams to invalidate old tokens or # tokens that may not have been generated by them. - def get(self): + def get(self) -> dict[str, dict[str, bool | str]]: token = request.args.get('token') # This will throw an error if token is not valid already # just ignore these @@ -33,7 +33,7 @@ def get(self): webexteamssdk.WebexTeamsAPI(app.config['WEBEX_TEAMS_ACCESS_TOKEN']).messages.delete( messageId=decoded['messageId'] ) - except: + except Exception: pass return { @@ -43,5 +43,3 @@ def get(self): 'priority': 'success' } } - - diff --git a/services/auth/documents/__init__.py b/services/auth/documents/__init__.py index 9173a6c..80bdb89 100644 --- a/services/auth/documents/__init__.py +++ b/services/auth/documents/__init__.py @@ -1 +1 @@ -from documents import person +from documents import person # noqa diff --git a/services/auth/documents/person.py b/services/auth/documents/person.py index acad3ec..000323a 100644 --- a/services/auth/documents/person.py +++ b/services/auth/documents/person.py @@ -12,7 +12,7 @@ class PersonDocument(BaseMixin, app.db.Document): ########## passwordHash = app.db.StringField(max_length=512, required=False, select=False) - email = app.db.StringField(max_length=64, unique=True, required=False) + email = app.db.StringField(max_length=64, unique=True, sparse=True, required=False) # Bot Fields displayName = app.db.StringField(required=True) @@ -25,7 +25,7 @@ class PersonDocument(BaseMixin, app.db.Document): def _hash(value: str) -> str: return hashlib.sha3_512(value.encode(), usedforsecurity=True).hexdigest() - def to_mongo(self, *args, **kwargs): + def to_mongo(self, *args: list, **kwargs: dict) -> dict: # Don't expose the password hash return {k: v for k, v in super().to_mongo(*args, **kwargs).items() if k != 'passwordHash'} diff --git a/services/auth/encoder.py b/services/auth/encoder.py index edd6740..1647497 100644 --- a/services/auth/encoder.py +++ b/services/auth/encoder.py @@ -9,11 +9,11 @@ class JWTEncoder(jwt.api_jwt.PyJWT): # 1 day if active - def __init__(self, *args, expiration=None, **kwargs): + def __init__(self, *args: list, expiration: dict[str, int] = None, **kwargs: dict) -> None: self._expiration = expiration or {'days': 1, 'seconds': 0} super().__init__(*args, **kwargs) - def encode(self, **token) -> str: + def encode(self, **token: dict) -> str: now = datetime.datetime.utcnow() expr = now + datetime.timedelta(**self._expiration) token = super().encode({ diff --git a/services/bot/migrations/20211029061051-add_email_and_password.js b/services/bot/migrations/20211029061051-add_email_and_password.js new file mode 100644 index 0000000..84693ee --- /dev/null +++ b/services/bot/migrations/20211029061051-add_email_and_password.js @@ -0,0 +1,17 @@ +module.exports = { + async up(db) { + await db.collection('people').createIndex({'email': 1}, {'sparse': true}) + await db.collection('people').updateMany({}, { $set: { + email: null, + passwordHash: null + }}); + }, + + async down(db) { + await db.collection('people').updateMany({}, { $unset: { + email: null, + passwordHash: null + }}); + await db.collection('people').dropIndex({'email_1': 1}) + } +}; diff --git a/services/mongo_backup/Dockerfile b/services/mongo_backup/Dockerfile new file mode 100644 index 0000000..9abe49b --- /dev/null +++ b/services/mongo_backup/Dockerfile @@ -0,0 +1,15 @@ +FROM ubuntu:trusty + +RUN apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 0C49F3730359A14518585931BC711F9BA15703C6 && \ + echo "deb [ arch=amd64 ] http://repo.mongodb.org/apt/ubuntu trusty/mongodb-org/3.4 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.4.list && \ + apt-get update && \ + apt-get install -y mongodb-org-shell mongodb-org-tools && \ + echo "mongodb-org-shell hold" | dpkg --set-selections && \ + echo "mongodb-org-tools hold" | dpkg --set-selections && \ + mkdir /backup + +ENV CRON_TIME="0 0 * * *" + +COPY ./run.sh /run.sh +VOLUME ["/backup"] +CMD ["/run.sh"] \ No newline at end of file diff --git a/services/mongo_backup/run.sh b/services/mongo_backup/run.sh new file mode 100755 index 0000000..a919608 --- /dev/null +++ b/services/mongo_backup/run.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +MONGODB_HOST=${MONGODB_PORT_27017_TCP_ADDR:-${MONGODB_HOST}} +MONGODB_HOST=${MONGODB_PORT_1_27017_TCP_ADDR:-${MONGODB_HOST}} +MONGODB_PORT=${MONGODB_PORT_27017_TCP_PORT:-${MONGODB_PORT}} +MONGODB_PORT=${MONGODB_PORT_1_27017_TCP_PORT:-${MONGODB_PORT}} +MONGODB_USER=${MONGODB_USER:-${MONGODB_ENV_MONGODB_USER}} +MONGODB_PASS=${MONGODB_PASS:-${MONGODB_ENV_MONGODB_PASS}} + +[[ ( -n "${MONGODB_PASS_FILE}" ) ]] && MONGODB_PASS=$(cat "${MONGODB_PASS_FILE}") + +[[ ( -z "${MONGODB_USER}" ) && ( -n "${MONGODB_PASS}" ) ]] && MONGODB_USER='admin' + +[[ ( -n "${MONGODB_USER}" ) ]] && USER_STR=" --username ${MONGODB_USER}" +[[ ( -n "${MONGODB_PASS}" ) ]] && PASS_STR=" --password ${MONGODB_PASS}" +[[ ( -n "${MONGODB_DB}" ) ]] && USER_STR=" --db ${MONGODB_DB}" + +BACKUP_CMD="mongodump --out /backup/"'${BACKUP_NAME}'" --host ${MONGODB_HOST} --port ${MONGODB_PORT} ${USER_STR}${PASS_STR}${DB_STR} ${EXTRA_OPTS}" + +echo "=> Creating backup script" +rm -f /backup.sh +cat <> /backup.sh +#!/bin/bash +MAX_BACKUPS=${MAX_BACKUPS} +BACKUP_NAME=\$(date +\%Y.\%m.\%d.\%H\%M\%S) + +echo "=> Backup started" +if ${BACKUP_CMD} ;then + echo " Backup succeeded" +else + echo " Backup failed" + rm -rf /backup/\${BACKUP_NAME} +fi + +if [ -n "\${MAX_BACKUPS}" ]; then + while [ \$(ls /backup -N1 | wc -l) -gt \${MAX_BACKUPS} ]; + do + BACKUP_TO_BE_DELETED=\$(ls /backup -N1 | sort | head -n 1) + echo " Deleting backup \${BACKUP_TO_BE_DELETED}" + rm -rf /backup/\${BACKUP_TO_BE_DELETED} + done +fi +echo "=> Backup done" +EOF +chmod +x /backup.sh + +echo "=> Creating restore script" +rm -f /restore.sh +cat <> /restore.sh +#!/bin/bash +echo "=> Restore database from \$1" +if mongorestore --host ${MONGODB_HOST} --port ${MONGODB_PORT} ${USER_STR}${PASS_STR} ${EXTRA_OPTS_RESTORE} \$1; then + echo " Restore succeeded" +else + echo " Restore failed" +fi +echo "=> Done" +EOF +chmod +x /restore.sh + +touch /mongo_backup.log +tail -F /mongo_backup.log & + +if [ -n "${INIT_BACKUP}" ]; then + echo "=> Create a backup on the startup" + /backup.sh +fi + +echo "${CRON_TIME} /backup.sh >> /mongo_backup.log 2>&1" > /crontab.conf +crontab /crontab.conf +echo "=> Running cron job" +exec cron -f \ No newline at end of file diff --git a/services/nginx/default.conf b/services/nginx/default.conf index 75bfb35..c3b4fa9 100644 --- a/services/nginx/default.conf +++ b/services/nginx/default.conf @@ -14,6 +14,10 @@ upstream users { server users:4000; } +upstream mongo_ui { + server mongo_ui:8081; +} + server { listen 80; server_name qutexbot.com www.qutexbot.com; @@ -54,4 +58,20 @@ server { location /api/v1/users { proxy_pass http://users; } + + location /admin/mongo { + auth_request /superadmin; + proxy_pass http://mongo_ui$request_uri; + } + + location = /superadmin { + internal; + proxy_pass http://auth/api/v1/auth/token/check?role=superadmin; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + } + + error_page 404 /not_found; + error_page 403 /access_denied; } \ No newline at end of file diff --git a/services/nginx/dev_default.conf b/services/nginx/dev_default.conf new file mode 100644 index 0000000..9ddae18 --- /dev/null +++ b/services/nginx/dev_default.conf @@ -0,0 +1,83 @@ +upstream ui { + server ui:3000; +} + +upstream auth { + server auth:4000; +} + +upstream projects { + server projects:4000; +} + +upstream users { + server users:4000; +} + +upstream mongo_ui { + server mongo_ui:8081; +} + +upstream redis_ui { + server redis_ui:8081; +} + +server { + listen 80; + server_name localhost; + + # React's hot reload feature requires this to work properly + # Only necessary for development + # TODO: build production config + location = /sockjs-node { + proxy_pass http://ui; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } + + location / { + proxy_pass http://ui; + } + + location /api/v1/auth { + proxy_pass http://auth; + } + + location /api/v1/projects { + proxy_pass http://projects; + } + + location /api/v1/users { + proxy_pass http://users; + } + + location /admin/mongo { + auth_request /superadmin; + proxy_pass http://mongo_ui$request_uri; + } + + location /admin/redis { + auth_request /superadmin; + proxy_pass http://redis_ui$request_uri; + } + + location = /superadmin { + internal; + proxy_pass http://auth/api/v1/auth/token/check?role=superadmin; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + } + + error_page 403 @access_denied; + error_page 404 @not_found; + + location @access_denied { + return 302 /access_denied; + } + + location @not_found { + return 302 /not_found; + } +} \ No newline at end of file diff --git a/services/nginx/tests/test_configs.py b/services/nginx/tests/test_configs.py new file mode 100644 index 0000000..79f70a2 --- /dev/null +++ b/services/nginx/tests/test_configs.py @@ -0,0 +1,20 @@ +import difflib +import os + + +class TestConfig: + FOLDER = 'services/nginx' + DEV = os.path.join(FOLDER, 'dev_default.conf') + PROD = os.path.join(FOLDER, 'default.conf') + + def test_configs_mostly_match(self) -> None: + with open(self.DEV) as dev: + with open(self.PROD) as prod: + diff = [ + i + for i in difflib.ndiff(dev.readlines(), prod.readlines()) + if i.startswith('-') or i.startswith('+') + ] + + # 12 lines are all that's needed for the diff between dev and prod + assert len(diff) == 12, "More than 12 lines are different between nginx configs" diff --git a/services/projects/api/__init__.py b/services/projects/api/__init__.py index 1da7b3d..2254c41 100644 --- a/services/projects/api/__init__.py +++ b/services/projects/api/__init__.py @@ -1 +1 @@ -from api import v1 \ No newline at end of file +from api import v1 # noqa diff --git a/services/projects/api/v1/__init__.py b/services/projects/api/v1/__init__.py index 822ce72..5e32f82 100644 --- a/services/projects/api/v1/__init__.py +++ b/services/projects/api/v1/__init__.py @@ -1 +1 @@ -from api.v1 import projects \ No newline at end of file +from api.v1 import projects # noqa diff --git a/services/projects/api/v1/projects.py b/services/projects/api/v1/projects.py index dc55b86..a5a2875 100644 --- a/services/projects/api/v1/projects.py +++ b/services/projects/api/v1/projects.py @@ -1,4 +1,5 @@ -from flask import request, jsonify +import requests +from flask import request from setup_api import v1 from documents.projects import ProjectDocument from flask_restx import Resource @@ -25,10 +26,22 @@ def get(self, projectId: int) -> dict[str, list]: @v1.route('/') class ProjectsApi(Resource): def get(self) -> dict[str, list]: - # TODO: authorize - page_num = request.args.get('page', 1) - limit = request.args.get('limit', 50) - data = [i.to_mongo() for i in ProjectDocument.objects.skip((page_num - 1) * limit).limit(limit)] + # Only get projects that user is an admin on + # TODO: this should be updated later to potentially all projects where the user has + # "read" access (E.G. been in the queue or is an admin) --> alternative + # definition: in a room that is registered to the project but is not an admin. + # TODO: clean this up + result = requests.get('http://users:4000/api/v1/users/me', cookies=request.cookies) + result.raise_for_status() + usersId = result.json()['data'][0]['id'] + data = [ + i.to_mongo() for i in ProjectDocument.objects( + __raw__={'admins.id': {'$eq': usersId}} + ).paginate( + page=request.args.get('page', 1), + per_page=request.args.get('limit', 50) + ).items + ] return {'data': data, 'total': len(data)} def post(self, **kwargs: dict[str, Any]) -> dict[str, Any]: diff --git a/services/projects/documents/__init__.py b/services/projects/documents/__init__.py index 9dd2b17..c9d5e8a 100644 --- a/services/projects/documents/__init__.py +++ b/services/projects/documents/__init__.py @@ -1 +1 @@ -from documents import projects \ No newline at end of file +from documents import projects # noqa diff --git a/services/ui/.eslintrc b/services/ui/.eslintrc index 79f2c40..d8e8f25 100644 --- a/services/ui/.eslintrc +++ b/services/ui/.eslintrc @@ -19,7 +19,8 @@ }, "ignorePatterns": [ "node_modules/", - "src/assets/js/" + "src/assets/js/", + "public/env.js" ], "rules": { "react/jsx-curly-spacing": [ diff --git a/services/ui/docker-entrypoint.sh b/services/ui/docker-entrypoint.sh index 71a7ee8..7018824 100755 --- a/services/ui/docker-entrypoint.sh +++ b/services/ui/docker-entrypoint.sh @@ -3,5 +3,5 @@ if [ "${DEVELOPMENT}" = "true" ]; then npm start else - serve -s build -l 3000 -n + react-dotenv && serve -s build -l 3000 -n fi \ No newline at end of file diff --git a/services/ui/install.sh b/services/ui/install.sh index 85f1e0b..0b1a383 100755 --- a/services/ui/install.sh +++ b/services/ui/install.sh @@ -1,7 +1,10 @@ #!/bin/sh if [ "${DEVELOPMENT}" = "true" ]; then - npm install -g npm-run-all@^4.1.5 react-scripts@^4.0.3 node-sass@^6.0.1 + npm install -g \ + npm-run-all@^4.1.5 \ + react-scripts@^4.0.3 \ + node-sass@^6.0.1 else npm install -g serve@^12.0.1 npm run build diff --git a/services/ui/package.json b/services/ui/package.json index 7741075..ba1849f 100644 --- a/services/ui/package.json +++ b/services/ui/package.json @@ -22,9 +22,9 @@ }, "scripts": { "watch-css": "sass --watch src/assets/sass:src/assets/css", - "start-js": "react-scripts start", + "start-js": "react-dotenv && react-scripts start", "start": "npm-run-all -p watch-css start-js", - "build": "react-scripts build", + "build": "react-dotenv && react-scripts build", "test": "npm lint && react-scripts test", "eject": "react-scripts eject", "lint": "eslint '**/+(*.jsx|*.js)'", @@ -35,6 +35,11 @@ "bugs": { "url": "https://github.com/amthorn/qutex/issues" }, + "react-dotenv": { + "whitelist": [ + "SUPER_ADMINS" + ] + }, "dependencies": { "@babel/core": "^7.13.10", "@fortawesome/fontawesome-free": "^5.15.1", @@ -56,6 +61,7 @@ "react-bootstrap": "^1.5.2", "react-chartjs-2": "^2.11.1", "react-dom": "^17.0.1", + "react-dotenv": "^0.1.3", "react-icons": "^4.2.0", "react-minimal-side-navigation": "^1.8.0", "react-router-dom": "^5.2.0", diff --git a/services/ui/public/index.html b/services/ui/public/index.html index c6ce5e8..aac631f 100644 --- a/services/ui/public/index.html +++ b/services/ui/public/index.html @@ -1,6 +1,20 @@ + + + @@ -18,7 +32,8 @@ Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. - --> + --> + Qutex + diff --git a/services/ui/src/components/ServerTable.jsx b/services/ui/src/components/ServerTable.jsx index 94ad696..fc21f5e 100644 --- a/services/ui/src/components/ServerTable.jsx +++ b/services/ui/src/components/ServerTable.jsx @@ -100,16 +100,15 @@ const ServerTable = ({ ), }); const [requestData, setRequestData] = useState({ + ...options_.requestParameterNames, limit: options_.perPage, page: 1, - orderBy: "created_at", - direction: 0, + query: "" }); const [total, setTotal] = useState(options.total ?? defaultTotal); const [isLoading, setIsLoading] = useState(false); const [cached, setCached] = useState(options.data); const [data, setData] = useState(options.data); - const [searchData, setSearchData] = useState(""); const emptyOrLoading = ( @@ -145,10 +144,20 @@ const ServerTable = ({ const handleSortColumnClick = (column) => { if (options_.sortable.includes(column)) { setIsLoading(true); - setRequestData({ - orderBy: requestData.orderBy === column ? requestData.column : column, - direction: requestData.orderBy === column && requestData.direction === 1 ? 0 : 1 - }); + if(requestData.orderBy === column){ + // If the column is already being sorted, reverse the order or revert to base, whatever is next + setRequestData({ + ...requestData, + orderBy: requestData.direction === 1 ? column : undefined, + direction: requestData.direction === 1 ? 0 : 1 + }); + }else{ + // if the column is not being sorted, set it to be sorted in direction 0 + setRequestData({ + orderBy: column, + direction: 1 + }); + } } }; @@ -214,7 +223,7 @@ const ServerTable = ({ for(const record of rawData.data){ for(const entry of Object.entries(record)){ // Ignore ID for searches, they are UUIDs - if(!ignoredKeys.has(entry[0]) && isSearchString(entry[1], searchData)){ + if(!ignoredKeys.has(entry[0]) && isSearchString(entry[1], requestData.query)){ matches.push(record); break; } @@ -274,14 +283,14 @@ const ServerTable = ({ // Search, Sort, and Paginate let newData = dta; - if(searchData){ + if(requestData.query){ newData = search(newData); } // Reset the total value after search is complete const newOptions = configureOptions(optns, newData.data.length); - if(requestData.orderBy || requestData.direction){ + if(requestData.orderBy || requestData.direction !== undefined){ newData = sort(newData); } @@ -359,7 +368,7 @@ const ServerTable = ({ ); const commonProps = { - color: "info", + color: "link", className: "page-link" }; @@ -388,21 +397,11 @@ const ServerTable = ({ return paginationButtons; }; - const handleSearchClick = () => { - setIsLoading(true); - setRequestData({ - ...requestData, - query: searchData, - page: 1 - }); - }; - - React.useEffect(() => { - setIsLoading(true); - handleFetchData(); - }, []); + // React.useEffect(() => { + // setIsLoading(true); + // handleFetchData(); + // }, []); - React.useEffect(handleSearchClick, [searchData]); React.useEffect(handleFetchData, [requestData]); // TODO: improve complexity by making this cleaner and better @@ -441,8 +440,8 @@ const ServerTable = ({ className="form-control" style={ {height: 34} } placeholder={ options_.texts.search } - value={ searchData } - onChange={ event => setSearchData(event.target.value) } + value={ requestData.query } + onChange={ event => setRequestData({query: event.target.value}) } /> diff --git a/services/ui/src/components/Sidebar/NavSidebar.jsx b/services/ui/src/components/Sidebar/NavSidebar.jsx index 0009880..467f0b5 100644 --- a/services/ui/src/components/Sidebar/NavSidebar.jsx +++ b/services/ui/src/components/Sidebar/NavSidebar.jsx @@ -3,10 +3,14 @@ import { Sidebar } from "components/Sidebar/Sidebar"; import { withRouter } from "react-router-dom"; import { FaBook, + FaBuffer, FaClipboardCheck, - FaCubes, + FaCog, + FaCubes, + FaDatabase, FaGithub, FaProjectDiagram, + FaUserTie, } from "react-icons/fa"; // Import 'react-minimal-side-navigation/lib/ReactMinimalSideNavigation.css'; @@ -75,7 +79,28 @@ const navItems = { ], "/admin": [ - + { + path: "/admin", + name: "Admin Home", + icon: + }, + { + path: "/admin/settings", + name: "Settings", + icon: + }, + { + path: "/admin/mongo", + name: "Manage Mongo Database", + icon: , + redirect: true + }, + { + path: "/admin/redis", + name: "Manage Redis Database", + icon: , + redirect: true + }, ] }; @@ -88,14 +113,15 @@ const getNavItems = (path) => { return navItems["^/$"]; }; -const NavSidebar = withRouter(() => ( +const NavSidebar = withRouter(({ ...props }) => Qutex Logo } /> -)); +); export { getNavItems, diff --git a/services/ui/src/components/Sidebar/Sidebar.jsx b/services/ui/src/components/Sidebar/Sidebar.jsx index 7da0096..b701619 100644 --- a/services/ui/src/components/Sidebar/Sidebar.jsx +++ b/services/ui/src/components/Sidebar/Sidebar.jsx @@ -1,11 +1,11 @@ +import env from "react-dotenv"; import { Nav } from "react-bootstrap"; import PerfectScrollbar from "perfect-scrollbar"; import { PropTypes } from "prop-types"; import React from "react"; +import { useLocation } from "react-router-dom"; import { Alpha, ComingSoon } from "components/Components"; -import { NavLink, useLocation } from "react-router-dom"; -// import { NavLink as ReactstrapNavLink } from "reactstrap"; let ps; @@ -14,14 +14,18 @@ const getTo = (property, loc) => { return property.path; } if(property.path.startsWith("https://")){ - return {pathname: property.path}; + return property.path; } return [loc.pathname, property.path].join("/"); }; -export const Sidebar = ({ routes, logoElement, toggleSidebar }) => { +export const Sidebar = ({ routes, logoElement, toggleSidebar, identity }) => { const location = useLocation(); // eslint-disable-line no-shadow const sidebarReference = React.useRef(null); + let superAdmins = []; + if(env){ + superAdmins = JSON.parse(env.SUPER_ADMINS); + } // Verifies if routeName is the one active (in browser input) React.useEffect(() => { @@ -38,7 +42,19 @@ export const Sidebar = ({ routes, logoElement, toggleSidebar }) => { ps.destroy(); } }; - }); + }, []); + + const innerContent = (property) => +
+ { + React.isValidElement(property.icon) ? + property.icon : + + } + { property.name } + { property.comingSoon ? : undefined } + { property.alpha ? : undefined } +
; return (
@@ -47,37 +63,24 @@ export const Sidebar = ({ routes, logoElement, toggleSidebar }) => { { logoElement }
diff --git a/services/ui/src/components/layout/Layout.jsx b/services/ui/src/components/layout/Layout.jsx index db0d088..bc2f50b 100644 --- a/services/ui/src/components/layout/Layout.jsx +++ b/services/ui/src/components/layout/Layout.jsx @@ -14,13 +14,17 @@ import { } from "components/Components"; import React, { useEffect, useState } from "react"; -export const Layout = ({ location, history, ...props}) => { // eslint-disable-line no-shadow +const successStatusCode = 200; + +export const Layout = ({ location, history, permission, ...props}) => { // eslint-disable-line no-shadow const [sidebarOpened, setSidebarOpened] = useState(false); const [notFound, setNotFound] = useState(false); const [loading, setLoading] = useState(true); const [authenticated, setAuthenticated] = useState(false); + const [authorized, setAuthorized] = useState(false); const [breadcrumbs, setBreadcrumbs] = useState([]); const [pageData, setPageData] = useState(); + const [identity, setIdentity] = useState({}); const toggleSidebar = () => { document.documentElement.classList.toggle("nav-open"); @@ -28,11 +32,26 @@ export const Layout = ({ location, history, ...props}) => { // eslint-disable-li }; useEffect(() => { - authCheck().then(success => { - setAuthenticated(success); - setLoading(false); - return success; + let isMounted = true; + authCheck({ permission: undefined }).then(({response, data}) => { + if(isMounted){ + setAuthenticated(response.status === successStatusCode && data.data.success === true); + setIdentity(data.token); + if (permission) { + authCheck({ permission }).then(({response, data}) => { // eslint-disable-line no-shadow + if(isMounted){ + setAuthorized(response.status === successStatusCode && data.data.success === true); + setLoading(false); + } + return response; + }).catch(alert); + } else { + setLoading(false); + } + } + return response; }).catch(alert); + return () => { isMounted = false; } }, []); const content = () => <> @@ -43,6 +62,7 @@ export const Layout = ({ location, history, ...props}) => { // eslint-disable-li history={ history } /> @@ -54,7 +74,7 @@ export const Layout = ({ location, history, ...props}) => { // eslint-disable-li setBreadcrumbs= { setBreadcrumbs } setData={ setPageData } > - { React.createElement(props.component, { ...props, pageData }) } + { React.createElement(props.component, { ...props, pageData, identity }) } @@ -72,6 +92,10 @@ export const Layout = ({ location, history, ...props}) => { // eslint-disable-li return ; } + if (permission && !authorized && !loading) { + return ; + } + return
{ authenticated && !loading ? content() : } diff --git a/services/ui/src/functions/auth.jsx b/services/ui/src/functions/auth.jsx index 1517b04..9f0aa78 100644 --- a/services/ui/src/functions/auth.jsx +++ b/services/ui/src/functions/auth.jsx @@ -1,9 +1,11 @@ /* eslint-disable no-magic-numbers */ import { request } from "./request.jsx"; -const authCheck = () => - request("/api/v1/auth/token/check", { method: "GET" }, { notifications: false }).then( - response => response.response.status === 200 && response.data.data.success === true +const authCheck = ({ permission }) => + request( + `/api/v1/auth/token/check${permission ? `?role=${permission}` : ""}`, + { method: "GET" }, + { notifications: false } ); const login = (email, password) => diff --git a/services/ui/src/index.js b/services/ui/src/index.js index 0590150..6cf464d 100644 --- a/services/ui/src/index.js +++ b/services/ui/src/index.js @@ -22,7 +22,6 @@ import "assets/css/main.css"; /* eslint-enable */ - /* eslint-disable react/jsx-filename-extension */ ReactDOM.render(( diff --git a/services/ui/src/views/Admin/AdminHome.jsx b/services/ui/src/views/Admin/AdminHome.jsx new file mode 100644 index 0000000..101c67e --- /dev/null +++ b/services/ui/src/views/Admin/AdminHome.jsx @@ -0,0 +1,16 @@ +import React from "react"; +import { + Card, + CardBody, + CardFooter, + CardHeader +} from "reactstrap"; + +export const AdminHome = () => + + +
Admin
+
+ idk + +
; diff --git a/services/ui/src/views/Admin/AdminSettings.jsx b/services/ui/src/views/Admin/AdminSettings.jsx new file mode 100644 index 0000000..61dee59 --- /dev/null +++ b/services/ui/src/views/Admin/AdminSettings.jsx @@ -0,0 +1,16 @@ +import React from "react"; +import { + Card, + CardBody, + CardFooter, + CardHeader +} from "reactstrap"; + +export const AdminSettings = () => + + +
Admin
+
+ idk + +
; diff --git a/services/ui/src/views/Error.jsx b/services/ui/src/views/Error.jsx index a3829f1..dc9e94c 100644 --- a/services/ui/src/views/Error.jsx +++ b/services/ui/src/views/Error.jsx @@ -4,7 +4,7 @@ import { ThemeContextWrapper } from "components/Components"; import { themes } from "components/layout/ThemeContext"; import { Container, Row } from "react-bootstrap"; -export const NotFoundPage = () => +const NotFoundPage = () =>
@@ -21,4 +21,19 @@ export const NotFoundPage = () =>
-
; \ No newline at end of file + ; + +const AccessDenied = () => + + +

Access Denied:(

+
+ + 403 Access Denied + +
; + +export { + AccessDenied, + NotFoundPage +}; \ No newline at end of file diff --git a/services/ui/src/views/Routes.jsx b/services/ui/src/views/Routes.jsx index 5625379..7090bf9 100644 --- a/services/ui/src/views/Routes.jsx +++ b/services/ui/src/views/Routes.jsx @@ -1,7 +1,8 @@ +import { AdminHome } from "views/Admin/AdminHome"; +import { AdminSettings } from "views/Admin/AdminSettings"; import { Home } from "views/Home"; import { Layout } from "components/layout/Layout"; import { Login } from "views/Auth/Login"; -import { NotFoundPage } from "views/Error"; import { Profile } from "views/User/Profile"; import { Project } from "views/Project/Project"; import { Projects } from "views/Project/Projects"; @@ -9,12 +10,18 @@ import React from "react"; import { Register } from "views/Auth/Register"; import { Statistics } from "views/Statistics/Statistics"; import { TokenVerify } from "views/Auth/TokenVerify"; +import { AccessDenied, NotFoundPage } from "views/Error"; import { Route, Switch } from "react-router-dom"; const layoutRender = component => properties => ; + +const adminLayoutRender = component => properties => + ; + + const routes = [ // Projects // { path: "/projects", exact: true, component: layoutRender(Projects) }, @@ -26,12 +33,20 @@ const routes = [ // User // { path: "/user", exact: true, component: layoutRender(Profile) }, - // TODO: Authenticate/authorize // + // Admin // + { path: "/admin/settings", exact: true, component: adminLayoutRender(AdminSettings) }, + { path: "/admin", exact: true, component: adminLayoutRender(AdminHome) }, + // Common // { path: "/", exact: true, component: layoutRender(Home) }, { path: "/login", exact: true, component: Login }, { path: "/register", exact: true, component: Register }, { path: "/token_verify", exact: true, component: TokenVerify }, + + // Error Pages // + { path: "/access_denied", component: layoutRender(AccessDenied), status: 403 }, + + // Not Found // { path: "*", component: () => , status: 404 }, ]; diff --git a/services/ui/src/views/User/Profile.jsx b/services/ui/src/views/User/Profile.jsx index 5d7a6d4..82fda85 100644 --- a/services/ui/src/views/User/Profile.jsx +++ b/services/ui/src/views/User/Profile.jsx @@ -35,7 +35,7 @@ export const Profile = function() { setFetching(false); }); }, []); - + if(fetching) { return ; } diff --git a/services/users/api/__init__.py b/services/users/api/__init__.py index 1da7b3d..94501b9 100644 --- a/services/users/api/__init__.py +++ b/services/users/api/__init__.py @@ -1 +1 @@ -from api import v1 \ No newline at end of file +from api import v1 # noqa \ No newline at end of file diff --git a/services/users/api/v1/__init__.py b/services/users/api/v1/__init__.py index ee1819c..f5c4fd0 100644 --- a/services/users/api/v1/__init__.py +++ b/services/users/api/v1/__init__.py @@ -1 +1 @@ -from api.v1 import me \ No newline at end of file +from api.v1 import me # noqa diff --git a/services/users/api/v1/me.py b/services/users/api/v1/me.py index 6993300..335c17f 100644 --- a/services/users/api/v1/me.py +++ b/services/users/api/v1/me.py @@ -1,14 +1,15 @@ from app import app from flask import request -from setup_api import v1 from flask_restx import Resource import requests import json -from typing import Any +from setup_api import v1 from webexteamssdk import WebexTeamsAPI + @v1.route('/me') class MeApi(Resource): def get(self) -> dict[str, list]: result = requests.get('http://auth:4000/api/v1/auth/token/check', cookies=request.cookies) - return {'data': [json.loads(WebexTeamsAPI(app.config['WEBEX_TEAMS_ACCESS_TOKEN']).people.get(result.json()['_token']['userId']).to_json())]} + bot = WebexTeamsAPI(app.config['WEBEX_TEAMS_ACCESS_TOKEN']) + return {'data': [json.loads(bot.people.get(result.json()['token']['userId']).to_json())]} diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6623b4f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[flake8] +ignore = ANN101,W504 +max-complexity = 10 +max-line-length = 100 \ No newline at end of file