├── .env ├── .gitignore ├── .gitlab-ci.yml ├── README.md ├── backend ├── .env ├── .gitignore ├── Dockerfile ├── backend │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── auth.py │ │ └── user.py │ ├── config.py │ ├── demo │ │ ├── __init__.py │ │ ├── messages.py │ │ └── model │ │ │ ├── __init__.py │ │ │ └── message.py │ ├── model │ │ ├── __init__.py │ │ ├── superadmin.py │ │ └── user.py │ ├── schema │ │ ├── __init__.py │ │ └── user.py │ └── templates │ │ ├── base.html │ │ ├── email_reset_password.html │ │ └── email_user_created.html ├── migrations │ ├── README │ ├── alembic.ini │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 01d29545f949_create_demo_message_table.py │ │ └── 4522be0c4858_initial_migration_create_user_table.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements-test.txt ├── requirements.txt └── tests │ ├── __init__.py │ ├── auth.py │ ├── conftest.py │ ├── test_messages.py │ └── test_superadmin.py ├── docker-compose.override.yml ├── docker-compose.yml ├── frontend ├── .gitignore ├── .npmrc ├── Dockerfile ├── README.md ├── app.vue ├── components │ ├── Breadcrumb.vue │ ├── MessageModal.vue │ ├── Navigation.vue │ └── RoleSelect.vue ├── composables │ ├── useAPI.ts │ ├── useAuth.ts │ ├── useMessages.ts │ └── useUsers.ts ├── docker-entrypoint.sh ├── middleware │ └── auth.ts ├── nuxt.config.ts ├── package.json ├── pages │ ├── admin │ │ └── users │ │ │ ├── edit │ │ │ └── [id].vue │ │ │ ├── index.vue │ │ │ └── new.vue │ ├── index.vue │ ├── login.vue │ ├── reset-password │ │ ├── [token].vue │ │ └── index.vue │ └── set-password │ │ └── [token].vue ├── plugins │ └── api.ts ├── pnpm-lock.yaml ├── public │ └── favicon.ico ├── server │ ├── middleware │ │ └── api.ts │ └── tsconfig.json ├── tsconfig.json └── utils │ ├── auth.ts │ ├── messages.ts │ └── users.ts ├── nginx.conf.template └── start_devserver.sh /.env: -------------------------------------------------------------------------------- 1 | BASE_URI= 2 | TAG=latest 3 | SECRET_KEY=dev secret key not for production 4 | ADMIN_PASSWORD=dev admin password not for production 5 | DB_PASSWORD=dev db password not for production 6 | DB_HOST=db 7 | EMAIL_HOST=localhost 8 | EMAIL_PORT=25 9 | EMAIL_HOST_USER=dev@localhost 10 | EMAIL_HOST_PASSWORD= 11 | EMAIL_USE_TLS= 12 | EMAIL_USE_SSL= 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vim <3 2 | .*.sw* 3 | 4 | # /run volumes 5 | backend_run/ 6 | frontend_run/ 7 | proxy_run/ 8 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - build 3 | - test 4 | - release 5 | - deploy 6 | 7 | variables: 8 | DOCKER_TLS_CERTDIR: '/certs' 9 | TAG: $CI_COMMIT_SHA 10 | SSH_PRIVATE_KEY: 11 | description: 'Private SSH key for a given deployment environment.' 12 | TARGET_USER: 13 | description: 'User name for a given deployment environment.' 14 | TARGET_HOST: 15 | description: 'Host for a given deployment environment.' 16 | TARGET_DIRECTORY: 17 | description: 'Absolute path to target folder for a given deployment environment.' 18 | STAGING_URL: 19 | description: 'URL where the staging deployment can be seen.' 20 | PRODUCTION_URL: 21 | description: 'URL where the production deployment can be seen.' 22 | 23 | services: 24 | - docker:dind 25 | 26 | .docker: 27 | image: docker:27.4.1 28 | before_script: 29 | - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY 30 | 31 | .ssh: 32 | image: alpine:3.21.2 33 | before_script: 34 | - apk add openssh 35 | - eval $(ssh-agent -s) 36 | - mkdir -p ~/.ssh 37 | - echo -e "Host *\n\tStrictHostKeyChecking accept-new\n" > ~/.ssh/config 38 | 39 | workflow: 40 | rules: 41 | - if: $CI_PIPELINE_SOURCE == "merge_request_event" 42 | - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS 43 | when: never 44 | - if: $CI_COMMIT_BRANCH 45 | 46 | build: 47 | stage: build 48 | extends: 49 | - .docker 50 | script: 51 | - docker compose build 52 | - docker compose push 53 | 54 | test: 55 | stage: test 56 | extends: 57 | - .docker 58 | script: 59 | - docker compose pull backend 60 | - docker compose up -d db 61 | - | 62 | echo ' 63 | pip install -r requirements-test.txt && 64 | ruff check && \ 65 | mypy && \ 66 | COVERAGE_FILE=/tmp/coverage \ 67 | pytest -o cache_dir=/tmp/cache 68 | ' | docker compose run --rm backend sh 69 | 70 | deploy:staging: 71 | stage: deploy 72 | extends: 73 | - .ssh 74 | script: 75 | - ssh-add <(echo "$SSH_PRIVATE_KEY") 76 | - | 77 | scp \ 78 | docker-compose.yml \ 79 | nginx.conf.template \ 80 | "${TARGET_USER}@${TARGET_HOST}:${TARGET_DIRECTORY}/" 81 | - | 82 | ssh "${TARGET_USER}@${TARGET_HOST}" \ 83 | " 84 | cd \"${TARGET_DIRECTORY}\" && 85 | docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY && 86 | export TAG=$TAG CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE && 87 | docker compose pull && 88 | rm -rf ~/.docker && 89 | docker compose \\ 90 | up \\ 91 | --renew-anon-volumes \\ 92 | --no-deps \\ 93 | --detach \\ 94 | && 95 | echo 'flask db upgrade' \\ 96 | | docker compose run --rm backend sh && 97 | echo ' 98 | pip install -r requirements-test.txt && 99 | COVERAGE_FILE=/tmp/coverage \\ 100 | pytest -o cache_dir=/tmp/cache 101 | ' | docker compose run --rm backend sh && 102 | docker image prune -f 103 | " 104 | environment: 105 | name: staging 106 | url: $STAGING_URL 107 | rules: 108 | - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH 109 | - if: $CI_COMMIT_BRANCH == "staging" 110 | 111 | deploy:production: 112 | stage: deploy 113 | extends: 114 | - .ssh 115 | script: 116 | - ssh-add <(echo "$SSH_PRIVATE_KEY") 117 | - | 118 | scp \ 119 | docker-compose.yml \ 120 | nginx.conf.template \ 121 | "${TARGET_USER}@${TARGET_HOST}:${TARGET_DIRECTORY}/" 122 | - | 123 | ssh "${TARGET_USER}@${TARGET_HOST}" \ 124 | " 125 | cd \"${TARGET_DIRECTORY}\" && 126 | docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY && 127 | export TAG=$TAG CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE && 128 | docker compose pull && 129 | rm -rf ~/.docker && 130 | docker compose \\ 131 | up \\ 132 | --renew-anon-volumes \\ 133 | --no-deps \\ 134 | --detach \\ 135 | && 136 | echo 'flask db upgrade' \\ 137 | | docker compose run --rm backend sh && 138 | docker image prune -f 139 | " 140 | environment: 141 | name: production 142 | url: $PRODUCTION_URL 143 | when: manual 144 | rules: 145 | - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Nuxt/Flask/NGINX boilerplate 2 | ============================ 3 | 4 | Useful to quickly get started on building Flask apps with modern frontend practices. 5 | 6 | Installation 7 | ------------ 8 | 9 | 1. Clone this repository: `git clone https://github.com/Cheaterman/fluxt && cd fluxt` 10 | 1. Install uwsgi - example for Debian: `apt install uwsgi` 11 | 1. Start the database server: `docker-compose up -d db` 12 | 1. Create the backend virtualenv and activate it: `cd backend && python3 -mvenv env && source env/bin/activate` 13 | 1. Install backend runtime & test dependencies: `pip install -r requirements.txt -r requirements-test.txt` 14 | 1. Run database migrations: `flask db upgrade` 15 | 1. Run the development server: `cd .. && ./start_devserver.sh` 16 | 1. In a few seconds, the example chat app should be available at http://localhost:8080/ (if you're getting 502 Bad Gateway, be patient and try again :-) ) 17 | -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | FLASK_APP='backend:create_app()' 2 | SECRET_KEY=dev secret key not for production 3 | ADMIN_PASSWORD=dev admin password not for production 4 | DB_PASSWORD=dev db password not for production 5 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .pyc 2 | __pycache__/ 3 | /.coverage 4 | /env/ 5 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM cheaterman/flask 2 | COPY . /code 3 | RUN su uwsgi -c 'pip install --no-cache-dir -U -r /code/requirements.txt' 4 | -------------------------------------------------------------------------------- /backend/backend/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_marshmallow_openapi import OpenAPISettings, OpenAPI # type: ignore 3 | 4 | 5 | def nuxtify(url: str) -> str: 6 | host, _, path = url.partition('/api/') 7 | return f'{host}/{path}' 8 | 9 | 10 | def create_app(config: dict[str, str | float] | None = None) -> Flask: 11 | app = Flask(__name__) 12 | app.config.from_pyfile('config.py') 13 | 14 | if config: 15 | app.config.from_mapping(config) 16 | 17 | from .model import db, migrate 18 | db.init_app(app) 19 | migrate.init_app(app, db) 20 | 21 | # See https://github.com/marshmallow-code/apispec/issues/444 22 | import warnings 23 | warnings.filterwarnings( 24 | "ignore", 25 | message="Multiple schemas resolved to the name " 26 | ) 27 | 28 | from .api import api 29 | app.register_blueprint(api) 30 | 31 | # XXX: Remove this for production 32 | from .demo import api 33 | app.register_blueprint(api) 34 | 35 | if ( 36 | app.config.get('ENABLE_DOCS') 37 | # For coverage 38 | or app.config.get('TESTING') 39 | ): 40 | docs = OpenAPI(config=OpenAPISettings( 41 | api_name='Fluxt API', 42 | api_version='v1', 43 | app_package_name=f'{__name__}.api', 44 | )) 45 | docs.init_app(app) 46 | 47 | return app 48 | -------------------------------------------------------------------------------- /backend/backend/api/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, Request, request, session 2 | from flask.blueprints import BlueprintSetupState 3 | from flask.typing import ResponseReturnValue 4 | from marshmallow import ValidationError 5 | from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound 6 | 7 | api = Blueprint('api', __name__) 8 | 9 | from backend.model.superadmin import SuperAdmin # noqa: E402 10 | from backend.model.user import Role, User, UserConverter # noqa: E402 11 | from .auth import Authable, auth # noqa: E402 12 | 13 | error_messages = { 14 | 401: 'unauthorized', 15 | 403: 'forbidden', 16 | } 17 | 18 | 19 | @api.record_once 20 | def register_url_converters(state: BlueprintSetupState) -> None: 21 | state.app.url_map.converters['user'] = UserConverter 22 | 23 | 24 | def raise_expected_json(request: Request, error: BadRequest) -> BadRequest: 25 | raise BadRequest('expected_json') 26 | 27 | 28 | setattr(Request, 'on_json_loading_failed', raise_expected_json) 29 | 30 | 31 | @api.errorhandler(BadRequest) 32 | @api.errorhandler(Conflict) 33 | @api.errorhandler(Forbidden) # Business logic 403, not RBAC 34 | @api.errorhandler(NotFound) 35 | def http_error( 36 | error: BadRequest | Conflict | Forbidden | NotFound, 37 | ) -> ResponseReturnValue: 38 | return {'message': error.description}, error.code 39 | 40 | 41 | @api.errorhandler(ValidationError) 42 | def validation_error(error: ValidationError) -> ResponseReturnValue: 43 | return error.messages_dict, 400 44 | 45 | 46 | @auth.error_handler 47 | def error_handler(status_code: int) -> ResponseReturnValue: 48 | return { 49 | 'message': error_messages.get(status_code, 'unknown_error'), 50 | }, status_code 51 | 52 | 53 | @auth.verify_password 54 | def verify_password(username: str, password: str) -> Authable | None: 55 | user = ( 56 | SuperAdmin.auth(username, password) 57 | or User.auth(username, password) 58 | ) 59 | 60 | if username and password and user: 61 | # Successful fresh login, evaluate & apply remember me status 62 | remember_me = { 63 | 'true': True, 64 | 'false': False, 65 | }.get(request.headers.get('Fluxt-Remember-Me', 'false'), False) 66 | session.permanent = remember_me 67 | 68 | return user 69 | 70 | 71 | @auth.get_user_roles 72 | def get_user_roles(user: Authable) -> list[Role]: 73 | return [user.get_role()] 74 | 75 | 76 | from . import ( # noqa: F401, E402 77 | user as _user, 78 | ) 79 | -------------------------------------------------------------------------------- /backend/backend/api/auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | 5 | from flask import Blueprint, session 6 | from flask.typing import ResponseReturnValue 7 | from flask_httpauth import HTTPBasicAuth 8 | from flask_marshmallow_openapi import open_api 9 | from marshmallow import Schema, fields 10 | 11 | from backend import api as api_module 12 | from backend.model.user import Role 13 | from backend.schema import EmptySchema 14 | 15 | 16 | api = typing.cast(Blueprint, api_module.api) # type: ignore 17 | auth = HTTPBasicAuth(scheme='BasicAPI') 18 | 19 | 20 | class AuthInfo(typing.TypedDict): 21 | id: str 22 | email: str 23 | role: Role 24 | first_name: str 25 | last_name: str 26 | 27 | 28 | class Authable(typing.Protocol): 29 | @classmethod 30 | def auth(cls, username: str, password: str) -> Authable | None: ... 31 | 32 | def get_role(self) -> Role: ... 33 | 34 | def get_auth_info(self) -> AuthInfo: ... 35 | 36 | 37 | class AuthSchema(Schema): 38 | id = fields.String() 39 | email = fields.String() 40 | role = fields.String() 41 | first_name = fields.String() 42 | last_name = fields.String() 43 | 44 | 45 | @open_api.get(AuthSchema, operation_id='auth') 46 | @api.get('/auth') 47 | @auth.login_required 48 | def authenticate() -> ResponseReturnValue: 49 | return typing.cast(Authable, auth.current_user()).get_auth_info() 50 | 51 | 52 | @open_api.get(EmptySchema, operation_id='deauth') 53 | @api.get('/deauth') 54 | def deauthenticate() -> ResponseReturnValue: 55 | session.clear() 56 | return '', 204 57 | -------------------------------------------------------------------------------- /backend/backend/api/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, cast 2 | 3 | from flask import abort, jsonify, request 4 | from flask.typing import ResponseReturnValue 5 | from flask_marshmallow_openapi import open_api 6 | from marshmallow import Schema, fields 7 | 8 | from backend.model import db 9 | from backend.model.user import Role, User 10 | from backend.schema import CreateSchema, EmptySchema 11 | from backend.schema.user import UserSchema 12 | from . import api 13 | from .auth import auth 14 | 15 | 16 | @open_api.post(UserSchema, CreateSchema) 17 | @api.post('/users') 18 | @auth.login_required(role=Role.ADMINISTRATOR) 19 | def create_user() -> ResponseReturnValue: 20 | user = UserSchema(exclude=['id', 'creation_date']).load(request.json) 21 | 22 | User.check_duplicate(user) 23 | 24 | db.session.add(user) 25 | db.session.commit() 26 | 27 | user.send_created_email() 28 | return {'id': str(user.id)}, 201 29 | 30 | 31 | @open_api.post(EmptySchema) 32 | @api.post('/users//send-created-email') 33 | @auth.login_required(role=Role.ADMINISTRATOR) 34 | def send_user_created_email(user: User) -> ResponseReturnValue: 35 | user.send_created_email() 36 | return '', 204 37 | 38 | 39 | @api.put('/users/') 40 | @auth.login_required(role=Role.ADMINISTRATOR) 41 | def update_user(user: User) -> ResponseReturnValue: 42 | UserSchema(exclude=['id', 'creation_date', 'email']).load( 43 | request.json, 44 | instance=user, 45 | partial=True, 46 | ) 47 | db.session.commit() 48 | return jsonify(UserSchema().dump(user)) 49 | 50 | 51 | @open_api.get_list(UserSchema) 52 | @api.get('/users') 53 | @auth.login_required(role=Role.ADMINISTRATOR) 54 | def list_users() -> ResponseReturnValue: 55 | users = User.query.order_by(User.creation_date) 56 | return {'users': UserSchema(many=True).dump(users)} 57 | 58 | 59 | @open_api.get(UserSchema) 60 | @api.get('/users/') 61 | @auth.login_required(role=Role.ADMINISTRATOR) 62 | def get_user(user: User) -> ResponseReturnValue: 63 | return jsonify(UserSchema().dump(user)) 64 | 65 | 66 | @open_api.get(EmptySchema, operation_id='get_password_state') 67 | @api.get('/set-password/') 68 | def get_password_state(token: str) -> ResponseReturnValue: 69 | user = User.from_password_token(token) 70 | 71 | if not user: 72 | abort(404, 'user_not_found') 73 | 74 | if user.password: 75 | abort(409, 'password_already_set') 76 | 77 | return '', 204 78 | 79 | 80 | class PasswordSchema(Schema): 81 | password = fields.Str() 82 | 83 | 84 | @open_api.post( 85 | request_schema=PasswordSchema, 86 | response_schema=EmptySchema, 87 | operation_id='set_password', 88 | ) 89 | @api.post('/set-password/') 90 | def set_password(token: str) -> ResponseReturnValue: 91 | password = PasswordSchema().load( 92 | cast(dict[str, Any], request.json) 93 | )['password'] 94 | user = User.from_password_token(token) 95 | 96 | if not user: 97 | abort(404, 'user_not_found') 98 | 99 | if user.password: 100 | abort(409, 'password_already_set') 101 | 102 | user.set_password(password) 103 | db.session.commit() 104 | return '', 204 105 | 106 | 107 | @open_api.get(EmptySchema) 108 | @api.get('/reset-password/') 109 | def send_reset_password_email(email: str) -> ResponseReturnValue: 110 | user = User.from_email(email) 111 | 112 | if not user: 113 | abort(404, 'user_not_found') 114 | 115 | user.send_reset_password_email() 116 | return '', 204 117 | 118 | 119 | @open_api.post( 120 | request_schema=PasswordSchema, 121 | response_schema=EmptySchema, 122 | operation_id='reset_password', 123 | ) 124 | @api.post('/reset-password/') 125 | def reset_password(token: str) -> ResponseReturnValue: 126 | password = PasswordSchema().load( 127 | cast(dict[str, Any], request.json) 128 | )['password'] 129 | user = User.from_password_token(token) 130 | 131 | if not user: 132 | abort(404, 'user_not_found') 133 | 134 | user.set_password(password) 135 | db.session.commit() 136 | return '', 204 137 | 138 | 139 | @api.delete('/users/') 140 | @auth.login_required(role=Role.ADMINISTRATOR) 141 | def delete_user(user: User) -> ResponseReturnValue: 142 | db.session.delete(user) 143 | db.session.commit() 144 | return '', 204 145 | -------------------------------------------------------------------------------- /backend/backend/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | getenv = os.environ.get 4 | db_user = getenv('DB_USER', 'postgres') 5 | db_password = getenv('DB_PASSWORD', '') 6 | db_host = getenv('DB_HOST', 'localhost') 7 | db_port = getenv('DB_PORT', '5432') 8 | db_name = getenv('DB_NAME', db_user) 9 | 10 | SQLALCHEMY_DATABASE_URI = ( 11 | f'postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}' 12 | ) 13 | SECRET_KEY = getenv('SECRET_KEY') 14 | ADMIN_PASSWORD = getenv('ADMIN_PASSWORD') 15 | ENABLE_DOCS = getenv('ENABLE_DOCS') 16 | EMAIL_HOST = getenv('EMAIL_HOST', 'localhost') 17 | EMAIL_PORT = getenv('EMAIL_PORT', 25) 18 | EMAIL_HOST_USER = getenv('EMAIL_HOST_USER', 'dev@localhost') 19 | EMAIL_HOST_PASSWORD = getenv('EMAIL_HOST_PASSWORD', '') 20 | EMAIL_USE_TLS = bool(getenv('EMAIL_USE_TLS', False)) 21 | EMAIL_USE_SSL = bool(getenv('EMAIL_USE_SSL', False)) 22 | STREAM_REFRESH_INTERVAL = float(getenv('STREAM_REFRESH_INTERVAL', 1)) 23 | -------------------------------------------------------------------------------- /backend/backend/demo/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, Request 2 | from flask.blueprints import BlueprintSetupState 3 | from flask.typing import ResponseReturnValue 4 | from marshmallow import ValidationError 5 | from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound 6 | 7 | from .model.message import MessageConverter 8 | 9 | api = Blueprint('demo', __name__) 10 | 11 | 12 | @api.record_once 13 | def register_url_converters(state: BlueprintSetupState) -> None: 14 | state.app.url_map.converters['message'] = MessageConverter 15 | 16 | 17 | def raise_expected_json(request: Request, error: BadRequest) -> BadRequest: 18 | raise BadRequest('expected_json') 19 | 20 | 21 | setattr(Request, 'on_json_loading_failed', raise_expected_json) 22 | 23 | 24 | @api.errorhandler(BadRequest) 25 | @api.errorhandler(Conflict) 26 | @api.errorhandler(Forbidden) # Business logic 403, not RBAC 27 | @api.errorhandler(NotFound) 28 | def http_error( 29 | error: BadRequest | Conflict | Forbidden | NotFound, 30 | ) -> ResponseReturnValue: 31 | return {'message': error.description}, error.code 32 | 33 | 34 | @api.errorhandler(ValidationError) 35 | def validation_error(error: ValidationError) -> ResponseReturnValue: 36 | return error.messages_dict, 400 37 | 38 | 39 | from . import ( # noqa: F401, E402 40 | messages, 41 | ) 42 | -------------------------------------------------------------------------------- /backend/backend/demo/messages.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Any, Generator, cast 3 | 4 | import gevent 5 | from flask import Response, current_app, request, stream_with_context 6 | from flask.typing import ResponseReturnValue 7 | from marshmallow import Schema, fields 8 | from marshmallow.validate import Length 9 | from sqlalchemy import select 10 | 11 | from backend.api.auth import auth 12 | from backend.model import db 13 | from backend.model.superadmin import SuperAdmin 14 | from backend.model.user import Role, User 15 | from .model.message import Message 16 | from . import api 17 | 18 | 19 | def serialize_author(message: Message) -> str: 20 | author = message.author 21 | 22 | if not author: 23 | return 'Admin' 24 | 25 | return f'{author.first_name} {author.last_name}' 26 | 27 | 28 | class MessageSchema(Schema): 29 | id = fields.String() 30 | date = fields.DateTime() 31 | author = fields.Function(serialize=serialize_author) 32 | text = fields.String() 33 | 34 | 35 | @api.get('/messages') 36 | @auth.login_required 37 | def messages_stream() -> ResponseReturnValue: 38 | schema = MessageSchema() 39 | 40 | def stream() -> Generator[str, None, None]: 41 | last_date = datetime.datetime.min 42 | 43 | while True: 44 | for message in db.session.scalars( 45 | select(Message) 46 | .where(Message.date > last_date) 47 | .order_by(Message.date) 48 | ): 49 | yield f'data: {schema.dumps(message)}\n\n' 50 | last_date = message.date 51 | 52 | gevent.sleep(current_app.config['STREAM_REFRESH_INTERVAL']) 53 | yield ':heartbeat\n' 54 | 55 | return Response( 56 | stream_with_context(stream()), 57 | mimetype='text/event-stream', 58 | ) 59 | 60 | 61 | class CreateMessageSchema(Schema): 62 | text = fields.String(validate=Length(min=1)) 63 | 64 | 65 | @api.post('/messages') 66 | @auth.login_required 67 | def messages_add() -> ResponseReturnValue: 68 | data = CreateMessageSchema().load( 69 | cast(dict[str, Any], request.json) 70 | ) 71 | 72 | message = Message() 73 | message.text = data['text'] 74 | user = cast(User | SuperAdmin, auth.current_user()) 75 | 76 | if user.email != 'admin': 77 | message.author = cast(User, user) 78 | 79 | db.session.add(message) 80 | db.session.commit() 81 | 82 | return {'id': str(message.id)}, 201 83 | 84 | 85 | @api.delete('/messages/') 86 | @auth.login_required(role=Role.ADMINISTRATOR) 87 | def messages_delete(message: Message) -> ResponseReturnValue: 88 | db.session.delete(message) 89 | db.session.commit() 90 | 91 | return '', 204 92 | -------------------------------------------------------------------------------- /backend/backend/demo/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cheaterman/fluxt/f2ee9636054ecb6db00a6a6eca087dd0af037e89/backend/backend/demo/model/__init__.py -------------------------------------------------------------------------------- /backend/backend/demo/model/message.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | from typing import TypedDict 4 | 5 | from flask import abort, make_response 6 | from sqlalchemy import ForeignKey, func 7 | from sqlalchemy.orm import Mapped, mapped_column, relationship 8 | from werkzeug.routing import BaseConverter 9 | 10 | from backend.model import Model, db 11 | from backend.model.user import User 12 | 13 | 14 | class MessageInfo(TypedDict): 15 | id: str 16 | date: str 17 | author: str 18 | text: str 19 | 20 | 21 | class Message(Model): 22 | id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) 23 | date: Mapped[datetime.datetime] = mapped_column(server_default=func.now()) 24 | author_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey(User.id)) 25 | text: Mapped[str] 26 | 27 | author: Mapped[User | None] = relationship(User) 28 | 29 | 30 | class MessageConverter(BaseConverter): 31 | def to_python(self, value: str) -> Message: 32 | message = db.session.get(Message, value) 33 | 34 | if not message: 35 | abort(make_response({'message': 'message_not_found'}, 404)) 36 | 37 | return message 38 | -------------------------------------------------------------------------------- /backend/backend/model/__init__.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from flask_migrate import Migrate 4 | from flask_sqlalchemy import SQLAlchemy 5 | from sqlalchemy import MetaData 6 | from sqlalchemy.orm import DeclarativeBase 7 | 8 | 9 | class Base(DeclarativeBase): 10 | metadata = MetaData(naming_convention={ 11 | 'ix': 'ix_%(column_0_label)s', 12 | 'uq': 'uq_%(table_name)s_%(column_0_name)s', 13 | 'ck': 'ck_%(table_name)s_%(constraint_name)s', 14 | 'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s', 15 | 'pk': 'pk_%(table_name)s' 16 | }) 17 | 18 | 19 | db = SQLAlchemy(model_class=Base) 20 | migrate = Migrate() 21 | 22 | if typing.TYPE_CHECKING: 23 | from flask_sqlalchemy.model import Model as Model 24 | else: 25 | Model = db.Model 26 | -------------------------------------------------------------------------------- /backend/backend/model/superadmin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Self 4 | 5 | from flask import current_app, session 6 | 7 | from .user import Role 8 | 9 | 10 | class SuperAdmin: 11 | email = 'admin' 12 | first_name = 'Admin' 13 | last_name = '' 14 | 15 | @classmethod 16 | def auth(cls, email: str, password: str) -> Self | None: 17 | if session.get('admin'): 18 | return cls() 19 | 20 | admin: Self | None = cls.from_credentials(email, password) 21 | 22 | if admin: 23 | session['admin'] = True 24 | return admin 25 | 26 | return None 27 | 28 | def get_role(self) -> Role: 29 | return Role.ADMINISTRATOR 30 | 31 | def get_auth_info(self) -> AuthInfo: 32 | return { 33 | 'id': '', 34 | 'email': self.email, 35 | 'role': self.get_role(), 36 | 'first_name': self.first_name, 37 | 'last_name': self.last_name, 38 | } 39 | 40 | @classmethod 41 | def from_credentials(cls, email: str, password: str) -> Self | None: 42 | if ( 43 | email == cls.email 44 | and password == current_app.config['ADMIN_PASSWORD'] 45 | ): 46 | return cls() 47 | 48 | return None 49 | 50 | 51 | from backend.api.auth import AuthInfo # noqa: E402 52 | -------------------------------------------------------------------------------- /backend/backend/model/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import enum 5 | import uuid 6 | from typing import Self 7 | 8 | import bcrypt 9 | from flask import ( 10 | abort, 11 | current_app, 12 | make_response, 13 | render_template, 14 | session, 15 | url_for, 16 | ) 17 | from flask_emails import Message # type: ignore[import-untyped] 18 | from itsdangerous import BadSignature, URLSafeSerializer 19 | from sqlalchemy import func, text 20 | from sqlalchemy.ext.hybrid import hybrid_property 21 | from sqlalchemy.orm import Mapped, mapped_column 22 | from werkzeug.routing import BaseConverter 23 | 24 | from backend import nuxtify 25 | from . import Model, db 26 | 27 | 28 | class Role(enum.StrEnum): 29 | ADMINISTRATOR = enum.auto() 30 | USER = enum.auto() 31 | 32 | 33 | class User(Model): 34 | id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) 35 | creation_date: Mapped[datetime.datetime] = mapped_column( 36 | server_default=func.now(), 37 | ) 38 | _email: Mapped[str] = mapped_column('email', unique=True) 39 | first_name: Mapped[str] 40 | last_name: Mapped[str] 41 | password: Mapped[str | None] 42 | role: Mapped[Role] 43 | enabled: Mapped[bool] = mapped_column(server_default=text('TRUE')) 44 | 45 | @hybrid_property 46 | def email(self): 47 | return self._email 48 | 49 | @email.setter # type: ignore[no-redef] 50 | def email(self, value: str): 51 | self._email = value.lower() 52 | 53 | @email.expression # type: ignore[no-redef] 54 | def email(cls): 55 | return func.lower(cls._email) 56 | 57 | @classmethod 58 | def auth(cls, email: str, password: str) -> Self | None: 59 | user_id = session.get('user_id') 60 | 61 | if user_id: 62 | user = db.session.get(cls, user_id) 63 | else: 64 | user = cls.from_credentials(email, password) 65 | 66 | if user and user.enabled: 67 | session['user_id'] = user.id 68 | return user 69 | 70 | return None 71 | 72 | def get_role(self) -> Role: 73 | return Role(self.role) 74 | 75 | def get_auth_info(self) -> AuthInfo: 76 | return { 77 | 'id': str(self.id), 78 | 'email': self.email, 79 | 'role': self.get_role(), 80 | 'first_name': self.first_name, 81 | 'last_name': self.last_name, 82 | } 83 | 84 | @classmethod 85 | def from_email(cls, email: str) -> Self | None: 86 | user: Self | None = cls.query.filter_by( 87 | email=email.lower(), 88 | ).one_or_none() 89 | return user 90 | 91 | @classmethod 92 | def from_credentials(cls, email: str, password: str) -> Self | None: 93 | user = cls.from_email(email.lower()) 94 | 95 | if user and user.check_password(password): 96 | return user 97 | 98 | return None 99 | 100 | def check_password(self, password: str) -> bool: 101 | if not self.password: 102 | return False 103 | 104 | return bcrypt.checkpw( 105 | password.encode('utf8'), 106 | self.password.encode('utf8'), 107 | ) 108 | 109 | @classmethod 110 | def check_duplicate(cls, user: Self) -> None: 111 | with db.session.no_autoflush: 112 | count = cls.query.filter( 113 | cls.id != user.id, 114 | cls.email == user.email, 115 | ).count() 116 | 117 | if count: 118 | abort(409, 'duplicate_user') 119 | 120 | @classmethod 121 | def get_password_tokenizer(cls) -> URLSafeSerializer: 122 | return URLSafeSerializer( 123 | current_app.config['SECRET_KEY'], 124 | salt='password', 125 | ) 126 | 127 | def get_password_token(self) -> str: 128 | token = self.get_password_tokenizer().dumps({'id': str(self.id)}) 129 | assert isinstance(token, str) 130 | return token 131 | 132 | @classmethod 133 | def from_password_token(cls, token: str) -> User | None: 134 | try: 135 | data = cls.get_password_tokenizer().loads(token) 136 | except BadSignature: 137 | return None 138 | 139 | return db.session.get(cls, data['id']) 140 | 141 | def set_password(self, password: str) -> None: 142 | self.password = bcrypt.hashpw( 143 | password.encode('utf8'), 144 | bcrypt.gensalt(rounds=4 if current_app.config['TESTING'] else 12) 145 | ).decode('utf8') 146 | 147 | def send_created_email(self) -> None: 148 | url = nuxtify(url_for( 149 | 'api.get_password_state', 150 | token=self.get_password_token(), 151 | _external=True, 152 | )) 153 | 154 | email = Message( 155 | subject='Account creation', 156 | html=render_template( 157 | 'email_user_created.html', 158 | user=self, 159 | url=url, 160 | ), 161 | mail_from=('Fluxt', current_app.config['EMAIL_HOST_USER']), 162 | ) 163 | email.send(to=self.email) 164 | 165 | def send_reset_password_email(self) -> None: 166 | url = nuxtify(url_for( 167 | 'api.reset_password', 168 | token=self.get_password_token(), 169 | _external=True, 170 | )) 171 | 172 | email = Message( 173 | subject='Password reset', 174 | html=render_template( 175 | 'email_reset_password.html', 176 | user=self, 177 | url=url, 178 | ), 179 | mail_from=('Fluxt', current_app.config['EMAIL_HOST_USER']), 180 | ) 181 | email.send(to=self.email) 182 | 183 | 184 | class UserConverter(BaseConverter): 185 | def to_python(self, value: str) -> User: 186 | user = db.session.get(User, value) 187 | 188 | if not user: 189 | abort(make_response({'message': 'user_not_found'}, 404)) 190 | 191 | return user 192 | 193 | 194 | from backend.api.auth import AuthInfo # noqa: E402 195 | -------------------------------------------------------------------------------- /backend/backend/schema/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_marshmallow import Marshmallow 2 | from marshmallow import Schema, fields 3 | 4 | ma = Marshmallow() 5 | 6 | 7 | class EmptySchema(Schema): 8 | pass 9 | 10 | 11 | class CreateSchema(Schema): 12 | id = fields.String() 13 | -------------------------------------------------------------------------------- /backend/backend/schema/user.py: -------------------------------------------------------------------------------- 1 | from marshmallow import fields 2 | from marshmallow.validate import Length, OneOf 3 | 4 | from backend.model.user import Role, User 5 | from . import ma 6 | 7 | 8 | class UserSchema(ma.SQLAlchemyAutoSchema): # type: ignore 9 | class Meta: 10 | model = User 11 | load_instance = True 12 | exclude = ['password', '_email'] 13 | 14 | id = fields.String(required=True, dump_only=True) 15 | creation_date = fields.DateTime(required=True, dump_only=True) 16 | email = fields.Email(required=True) 17 | first_name = fields.String(required=True, validate=Length(min=1)) 18 | last_name = fields.String(required=True, validate=Length(min=1)) 19 | role = fields.String( 20 | required=True, 21 | validate=OneOf(Role.__members__.values()), 22 | ) 23 | enabled = fields.Boolean() 24 | -------------------------------------------------------------------------------- /backend/backend/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% block content %}{% endblock %} 4 | 5 | 6 | -------------------------------------------------------------------------------- /backend/backend/templates/email_reset_password.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 | 5 |

6 | Password reset 7 |

8 |

9 | Hello {{ user.first_name or user.last_name }},
10 |
11 | A password reset request was made for your account.
12 |
13 | Click the following link to reset your password.
14 | If you didn't make this request, you can ignore this e-mail.
15 |
16 | 17 | Reset your password 18 | 19 |

20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /backend/backend/templates/email_user_created.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 | 5 |

6 | Account creation 7 |

8 |

9 | Hello {{ user.first_name or user.last_name }},
10 |
11 | Your account was successfully created.
12 |
13 | Click the following link to set your password and access the platform.
14 |
15 | 16 | Set my password 17 | 18 |

19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /backend/migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /backend/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic,flask_migrate 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /backend/migrations/env.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.config import fileConfig 3 | 4 | from flask import current_app 5 | 6 | from alembic import context 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | fileConfig(config.config_file_name) 15 | logger = logging.getLogger('alembic.env') 16 | 17 | 18 | def get_engine(): 19 | # this works with Flask-SQLAlchemy>=3 20 | return current_app.extensions['migrate'].db.engine 21 | 22 | 23 | def get_engine_url(): 24 | try: 25 | return get_engine().url.render_as_string(hide_password=False).replace( 26 | '%', '%%') 27 | except AttributeError: 28 | return str(get_engine().url).replace('%', '%%') 29 | 30 | 31 | # add your model's MetaData object here 32 | # for 'autogenerate' support 33 | # from myapp import mymodel 34 | # target_metadata = mymodel.Base.metadata 35 | config.set_main_option('sqlalchemy.url', get_engine_url()) 36 | target_db = current_app.extensions['migrate'].db 37 | 38 | # other values from the config, defined by the needs of env.py, 39 | # can be acquired: 40 | # my_important_option = config.get_main_option("my_important_option") 41 | # ... etc. 42 | 43 | 44 | def get_metadata(): 45 | if hasattr(target_db, 'metadatas'): 46 | return target_db.metadatas[None] 47 | return target_db.metadata 48 | 49 | 50 | def run_migrations_offline(): 51 | """Run migrations in 'offline' mode. 52 | 53 | This configures the context with just a URL 54 | and not an Engine, though an Engine is acceptable 55 | here as well. By skipping the Engine creation 56 | we don't even need a DBAPI to be available. 57 | 58 | Calls to context.execute() here emit the given string to the 59 | script output. 60 | 61 | """ 62 | url = config.get_main_option("sqlalchemy.url") 63 | context.configure( 64 | url=url, target_metadata=get_metadata(), literal_binds=True 65 | ) 66 | 67 | with context.begin_transaction(): 68 | context.run_migrations() 69 | 70 | 71 | def run_migrations_online(): 72 | """Run migrations in 'online' mode. 73 | 74 | In this scenario we need to create an Engine 75 | and associate a connection with the context. 76 | 77 | """ 78 | 79 | # this callback is used to prevent an auto-migration from being generated 80 | # when there are no changes to the schema 81 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 82 | def process_revision_directives(context, revision, directives): 83 | if getattr(config.cmd_opts, 'autogenerate', False): 84 | script = directives[0] 85 | if script.upgrade_ops.is_empty(): 86 | directives[:] = [] 87 | logger.info('No changes in schema detected.') 88 | 89 | connectable = get_engine() 90 | 91 | with connectable.connect() as connection: 92 | context.configure( 93 | connection=connection, 94 | target_metadata=get_metadata(), 95 | process_revision_directives=process_revision_directives, 96 | **current_app.extensions['migrate'].configure_args 97 | ) 98 | 99 | with context.begin_transaction(): 100 | context.run_migrations() 101 | 102 | 103 | if context.is_offline_mode(): 104 | run_migrations_offline() 105 | else: 106 | run_migrations_online() 107 | -------------------------------------------------------------------------------- /backend/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /backend/migrations/versions/01d29545f949_create_demo_message_table.py: -------------------------------------------------------------------------------- 1 | """Create (demo) message table 2 | 3 | Revision ID: 01d29545f949 4 | Revises: 4522be0c4858 5 | Create Date: 2025-04-09 19:42:26.174893 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '01d29545f949' 14 | down_revision = '4522be0c4858' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | op.create_table( 21 | 'message', 22 | sa.Column('id', sa.Uuid(), nullable=False), 23 | sa.Column( 24 | 'date', 25 | sa.DateTime(), 26 | server_default=sa.text('now()'), 27 | nullable=False, 28 | ), 29 | sa.Column('author_id', sa.Uuid(), nullable=True), 30 | sa.Column('text', sa.String(), nullable=False), 31 | sa.ForeignKeyConstraint( 32 | ['author_id'], 33 | ['user.id'], 34 | name=op.f('fk_message_author_id_user'), 35 | ), 36 | sa.PrimaryKeyConstraint('id', name=op.f('pk_message')) 37 | ) 38 | 39 | 40 | def downgrade() -> None: 41 | op.drop_table('message') 42 | -------------------------------------------------------------------------------- /backend/migrations/versions/4522be0c4858_initial_migration_create_user_table.py: -------------------------------------------------------------------------------- 1 | """Initial migration - create user table 2 | 3 | Revision ID: 4522be0c4858 4 | Revises: 5 | Create Date: 2025-04-09 19:36:33.944119 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '4522be0c4858' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | op.create_table( 21 | 'user', 22 | sa.Column('id', sa.Uuid(), nullable=False), 23 | sa.Column( 24 | 'creation_date', 25 | sa.DateTime(), 26 | server_default=sa.text('now()'), 27 | nullable=False, 28 | ), 29 | sa.Column('email', sa.String(), nullable=False), 30 | sa.Column('first_name', sa.String(), nullable=False), 31 | sa.Column('last_name', sa.String(), nullable=False), 32 | sa.Column('password', sa.String(), nullable=True), 33 | sa.Column( 34 | 'role', 35 | sa.Enum('ADMINISTRATOR', 'USER', name='role'), 36 | nullable=False, 37 | ), 38 | sa.Column( 39 | 'enabled', 40 | sa.Boolean(), 41 | server_default=sa.text('TRUE'), 42 | nullable=False, 43 | ), 44 | sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), 45 | sa.UniqueConstraint('email', name=op.f('uq_user_email')) 46 | ) 47 | 48 | 49 | def downgrade() -> None: 50 | op.drop_table('user') 51 | op.execute('DROP TYPE role') 52 | -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.mypy] 2 | strict = true 3 | files = ['backend', 'tests'] 4 | 5 | [tool.pytest.ini_options] 6 | norecursedirs = ['.*', 'env'] 7 | addopts = [ 8 | '-Werror', 9 | '--cov=backend', 10 | '--cov-report=term-missing:skip-covered', 11 | '--color=yes', 12 | ] 13 | 14 | [tool.ruff] 15 | line-length = 79 16 | 17 | [tool.ruff.lint] 18 | extend-select = ["E", "W", "C"] 19 | preview = true 20 | -------------------------------------------------------------------------------- /backend/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pudb 2 | ruff 3 | -------------------------------------------------------------------------------- /backend/requirements-test.txt: -------------------------------------------------------------------------------- 1 | flask-httpauth-stubs 2 | mypy 3 | pytest 4 | pytest-cov 5 | ruff 6 | types-flask-migrate 7 | types-gevent 8 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | bcrypt 2 | flask 3 | flask-emails 4 | flask-httpauth 5 | flask-marshmallow 6 | flask-marshmallow-openapi==0.6.4 7 | flask-migrate 8 | flask-sqlalchemy 9 | gevent 10 | marshmallow 11 | marshmallow-sqlalchemy 12 | psycopg2-binary 13 | python-dotenv 14 | -------------------------------------------------------------------------------- /backend/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cheaterman/fluxt/f2ee9636054ecb6db00a6a6eca087dd0af037e89/backend/tests/__init__.py -------------------------------------------------------------------------------- /backend/tests/auth.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pytest 4 | from flask.testing import FlaskClient 5 | 6 | from backend.model.user import Role, User 7 | 8 | SESSIONS = { 9 | Role.ADMINISTRATOR: 'admin_session', 10 | Role.USER: 'user_session', 11 | } 12 | 13 | 14 | @pytest.fixture 15 | def admin_session(test_client: FlaskClient) -> None: 16 | with test_client.session_transaction() as session: 17 | session['admin'] = True 18 | 19 | 20 | @pytest.fixture 21 | def user_session( 22 | test_client: FlaskClient, 23 | user: User, 24 | user_password: str, # pylint: disable=unused-argument 25 | ) -> None: 26 | with test_client.session_transaction() as session: 27 | session['user_id'] = user.id 28 | 29 | 30 | @pytest.fixture(params=[None] + list( 31 | Role._member_map_.values() # pylint: disable=no-member,protected-access 32 | )) 33 | def role(request: pytest.FixtureRequest) -> Role | None: 34 | _role = typing.cast(Role, request.param) 35 | fixture = SESSIONS.get(_role) 36 | 37 | if fixture is None: 38 | return None 39 | 40 | request.getfixturevalue(fixture) 41 | return _role 42 | -------------------------------------------------------------------------------- /backend/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import collections.abc 2 | import os 3 | 4 | import dotenv 5 | import flask_migrate 6 | import pytest 7 | from flask import Flask 8 | from flask.testing import FlaskClient 9 | from flask_sqlalchemy import SQLAlchemy 10 | from flask_sqlalchemy.session import Session 11 | from sqlalchemy.orm import scoped_session 12 | 13 | from tests.auth import admin_session, role, user_session # noqa: F401 14 | from backend import create_app 15 | from backend.model import db as db_ 16 | 17 | 18 | @pytest.fixture(scope='session', autouse=True) 19 | def load_dotenv() -> None: 20 | dotenv.load_dotenv() 21 | 22 | 23 | @pytest.fixture(scope='session') 24 | def app() -> collections.abc.Generator[Flask, None, None]: 25 | app = create_app({ 26 | 'TESTING': 'True', 27 | 'STREAM_REFRESH_INTERVAL': 0, 28 | # Needed for redirecting to Nuxt 29 | 'APPLICATION_ROOT': os.environ.get('SCRIPT_NAME', '/api/'), 30 | 'EMAIL_HOST': 'localhost', 31 | 'EMAIL_USE_TLS': '', 32 | 'EMAIL_USE_SSL': '', 33 | }) 34 | yield app 35 | 36 | 37 | @pytest.fixture 38 | def test_client(app: Flask) -> FlaskClient: 39 | return app.test_client() 40 | 41 | 42 | @pytest.fixture(scope='session') 43 | def db(app: Flask) -> collections.abc.Generator[SQLAlchemy, None, None]: 44 | with app.app_context(): 45 | # See https://github.com/sqlalchemy/sqlalchemy/issues/11163 46 | engine = db_.engine 47 | connection = engine.connect() 48 | transaction = connection.begin() 49 | # pylint: disable=protected-access 50 | db_.session = db_._make_scoped_session({ 51 | 'bind': connection, 52 | 'join_transaction_mode': 'create_savepoint', 53 | }) 54 | # XXX: Maybe we can avoid monkeypatching here somehow 55 | db_.session.commit = db_.session.flush # type: ignore 56 | flask_migrate.upgrade() 57 | try: 58 | yield db_ 59 | finally: 60 | transaction.rollback() 61 | 62 | 63 | @pytest.fixture(autouse=True) 64 | def db_session(app: Flask, db: SQLAlchemy) -> collections.abc.Generator[ 65 | scoped_session[Session], 66 | None, 67 | None, 68 | ]: 69 | session = db.session 70 | 71 | with session.begin_nested() as transaction: 72 | try: 73 | yield session 74 | finally: 75 | transaction.rollback() 76 | -------------------------------------------------------------------------------- /backend/tests/test_messages.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from flask.testing import FlaskClient 4 | 5 | from backend.demo.model.message import Message 6 | 7 | 8 | def test_messages_stream( 9 | test_client: FlaskClient, 10 | admin_session: None, 11 | ) -> None: 12 | response = test_client.get('/messages') 13 | assert response.status_code == 200 14 | response_iterator = response.iter_encoded() 15 | 16 | # Ignore existing messages 17 | for _ in Message.query: 18 | next(response_iterator) 19 | 20 | assert next(response_iterator) == b':heartbeat\n' 21 | response = test_client.post('/messages', json={'text': 'Some text'}) 22 | message_id = response.get_json()['id'] 23 | data = json.loads(next(response_iterator).decode()[len('data: '):]) 24 | assert data['id'] == message_id 25 | assert data['text'] == 'Some text' 26 | 27 | 28 | def test_messages_post_invalid( 29 | test_client: FlaskClient, 30 | admin_session: None, 31 | ) -> None: 32 | before_count = Message.query.count() 33 | 34 | response = test_client.post('/messages') 35 | assert response.status_code == 400 36 | assert response.json == {'message': 'expected_json'} 37 | assert Message.query.count() == before_count 38 | 39 | response = test_client.post('/messages', json={'not_text': 'Some text'}) 40 | assert response.status_code == 400 41 | assert response.json == {'not_text': ['Unknown field.']} 42 | assert Message.query.count() == before_count 43 | 44 | 45 | def test_messages_post_empty( 46 | test_client: FlaskClient, 47 | admin_session: None, 48 | ) -> None: 49 | before_count = Message.query.count() 50 | 51 | response = test_client.post('/messages', json={'text': ''}) 52 | assert response.status_code == 400 53 | assert response.json == {'text': ['Shorter than minimum length 1.']} 54 | assert Message.query.count() == before_count 55 | 56 | 57 | def test_messages_post_valid( 58 | test_client: FlaskClient, 59 | admin_session: None, 60 | ) -> None: 61 | before_count = Message.query.count() 62 | 63 | response = test_client.get('/messages') 64 | response_iterator = response.iter_encoded() 65 | 66 | # Ignore existing messages 67 | for _ in Message.query: 68 | next(response_iterator) 69 | 70 | response = test_client.post('/messages', json={'text': 'Some text'}) 71 | assert Message.query.count() == before_count + 1 72 | last_message = Message.query.order_by(Message.date.desc()).first() 73 | assert last_message 74 | assert last_message.text == 'Some text' 75 | assert response.status_code == 201 76 | assert response.json == {'id': str(last_message.id)} 77 | 78 | assert next(response_iterator) == b':heartbeat\n' 79 | data = json.loads(next(response_iterator).decode()[len('data: '):]) 80 | assert data['id'] == str(last_message.id) 81 | assert data['text'] == 'Some text' 82 | 83 | 84 | def test_messages_delete_valid( 85 | test_client: FlaskClient, 86 | admin_session: None, 87 | ) -> None: 88 | before_count = Message.query.count() 89 | response = test_client.post('/messages', json={'text': 'Some text'}) 90 | message_id = response.get_json()['id'] 91 | assert Message.query.count() == before_count + 1 92 | 93 | response = test_client.delete(f'/messages/{message_id}') 94 | assert response.status_code == 204 95 | assert response.data == b'' 96 | assert Message.query.count() == before_count 97 | -------------------------------------------------------------------------------- /backend/tests/test_superadmin.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pytest 4 | from flask import Flask, session 5 | from flask.testing import FlaskClient 6 | 7 | from backend.model.user import Role 8 | 9 | 10 | @pytest.fixture 11 | def admin_password(app: Flask) -> str: 12 | return typing.cast(str, app.config['ADMIN_PASSWORD']) 13 | 14 | 15 | def test_config_missing_password( 16 | test_client: FlaskClient, 17 | app: Flask, 18 | admin_password: str, 19 | ) -> None: 20 | app.config['ADMIN_PASSWORD'] = '' 21 | response = test_client.get('/auth', auth=('admin', admin_password)) 22 | assert response.status_code == 401 23 | assert response.get_json()['message'] == 'unauthorized' 24 | 25 | 26 | def test_no_credentials(test_client: FlaskClient) -> None: 27 | response = test_client.get('/auth') 28 | assert response.status_code == 401 29 | assert response.get_json()['message'] == 'unauthorized' 30 | 31 | 32 | def test_invalid_username( 33 | test_client: FlaskClient, 34 | admin_password: str, 35 | ) -> None: 36 | response = test_client.get( 37 | '/auth', 38 | auth=('invalid_username', admin_password), 39 | ) 40 | assert response.status_code == 401 41 | assert response.get_json()['message'] == 'unauthorized' 42 | 43 | 44 | def test_invalid_password(test_client: FlaskClient) -> None: 45 | response = test_client.get('/auth', auth=('admin', 'invalid_password')) 46 | assert response.status_code == 401 47 | assert response.get_json()['message'] == 'unauthorized' 48 | 49 | 50 | def test_valid(test_client: FlaskClient, admin_password: str) -> None: 51 | with test_client: 52 | test_client.get('/auth') # Create session 53 | assert 'admin' not in session 54 | response = test_client.get('/auth', auth=('admin', admin_password)) 55 | assert response.status_code == 200 56 | assert response.get_json() == { 57 | 'id': '', 58 | 'email': 'admin', 59 | 'role': Role.ADMINISTRATOR.value, 60 | 'first_name': 'Admin', 61 | 'last_name': '', 62 | } 63 | assert session.get('admin') is True 64 | 65 | 66 | def test_cookie(test_client: FlaskClient, admin_session: None) -> None: 67 | response = test_client.get('/auth') 68 | assert response.status_code == 200 69 | assert response.get_json() == { 70 | 'id': '', 71 | 'email': 'admin', 72 | 'role': Role.ADMINISTRATOR.value, 73 | 'first_name': 'Admin', 74 | 'last_name': '', 75 | } 76 | 77 | 78 | def test_deauth(test_client: FlaskClient, admin_session: None) -> None: 79 | with test_client: 80 | response = test_client.get('/deauth') 81 | assert response.status_code == 204 82 | assert response.data == b'' 83 | assert 'admin' not in session 84 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | services: 2 | backend: 3 | build: backend 4 | volumes: 5 | - ./backend:/code 6 | environment: 7 | UWSGI_PY_AUTORELOAD: 1 8 | ENABLE_DOCS: 1 9 | 10 | db: 11 | ports: 12 | - '127.0.0.1:5432:5432' 13 | 14 | frontend: 15 | build: frontend 16 | volumes: 17 | - ./frontend:/home/node 18 | command: > 19 | sh -c ' 20 | pnpm install && 21 | pnpm dev 22 | ' 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | backend: 3 | image: fluxt/backend:${TAG} 4 | sysctls: 5 | net.core.somaxconn: 1024 6 | restart: unless-stopped 7 | depends_on: 8 | - db 9 | volumes: 10 | - ./backend_run:/run 11 | environment: 12 | SECRET_KEY: 13 | ADMIN_PASSWORD: 14 | DB_HOST: 15 | DB_PASSWORD: 16 | EMAIL_HOST: 17 | EMAIL_PORT: 18 | EMAIL_HOST_USER: 19 | EMAIL_HOST_PASSWORD: 20 | EMAIL_USE_TLS: 21 | EMAIL_USE_SSL: 22 | SCRIPT_NAME: $BASE_URI/api 23 | WSGI_MODULE: backend:create_app() 24 | # 101: nginx gid 25 | WSGI_SOCKET_GID: 101 26 | TZ: Europe/Paris 27 | 28 | db: 29 | image: postgres:17-alpine 30 | restart: unless-stopped 31 | volumes: 32 | - ./db_data:/var/lib/postgresql/data 33 | environment: 34 | POSTGRES_PASSWORD: $DB_PASSWORD 35 | TZ: Europe/Paris 36 | 37 | frontend: 38 | image: fluxt/frontend:${TAG} 39 | restart: unless-stopped 40 | volumes: 41 | - ./frontend_run:/run 42 | environment: 43 | NUXT_APP_BASE_URL: $BASE_URI 44 | # 101: nginx gid 45 | SOCKET_GID: 101 46 | TZ: Europe/Paris 47 | 48 | proxy: 49 | image: nginx 50 | depends_on: 51 | - backend 52 | - frontend 53 | restart: unless-stopped 54 | command: > 55 | sh -c ' 56 | envsubst \$$BASE_URI < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf && 57 | rm -f /run/nginx.* && 58 | exec /docker-entrypoint.sh nginx -g "daemon off;" 59 | ' 60 | volumes: 61 | - ./nginx.conf.template:/etc/nginx/nginx.conf.template:ro 62 | - ./proxy_run:/run 63 | - ./backend_run:/backend_run 64 | - ./frontend_run:/frontend_run 65 | environment: 66 | BASE_URI: 67 | TZ: Europe/Paris 68 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # pnpm store 13 | .pnpm-store 14 | 15 | # Logs 16 | logs 17 | *.log 18 | 19 | # Misc 20 | .DS_Store 21 | .fleet 22 | .idea 23 | 24 | # Local env files 25 | .env 26 | .env.* 27 | !.env.example 28 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | ARG NITRO_PRESET=node-cluster 4 | 5 | COPY docker-entrypoint.sh /usr/local/bin 6 | COPY . /home/node 7 | 8 | WORKDIR /home/node 9 | 10 | RUN \ 11 | apk add --no-cache --virtual .build-deps \ 12 | git \ 13 | && \ 14 | npm install -g pnpm && \ 15 | pnpm install --force && \ 16 | pnpm build && \ 17 | apk del --no-cache .build-deps 18 | 19 | CMD ["node", ".output/server/index.mjs"] 20 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 Minimal Starter 2 | 3 | Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. 4 | 5 | ## Setup 6 | 7 | Make sure to install the dependencies: 8 | 9 | ```bash 10 | # npm 11 | npm install 12 | 13 | # pnpm 14 | pnpm install 15 | 16 | # yarn 17 | yarn install 18 | 19 | # bun 20 | bun install 21 | ``` 22 | 23 | ## Development Server 24 | 25 | Start the development server on `http://localhost:3000`: 26 | 27 | ```bash 28 | # npm 29 | npm run dev 30 | 31 | # pnpm 32 | pnpm run dev 33 | 34 | # yarn 35 | yarn dev 36 | 37 | # bun 38 | bun run dev 39 | ``` 40 | 41 | ## Production 42 | 43 | Build the application for production: 44 | 45 | ```bash 46 | # npm 47 | npm run build 48 | 49 | # pnpm 50 | pnpm run build 51 | 52 | # yarn 53 | yarn build 54 | 55 | # bun 56 | bun run build 57 | ``` 58 | 59 | Locally preview production build: 60 | 61 | ```bash 62 | # npm 63 | npm run preview 64 | 65 | # pnpm 66 | pnpm run preview 67 | 68 | # yarn 69 | yarn preview 70 | 71 | # bun 72 | bun run preview 73 | ``` 74 | 75 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 76 | -------------------------------------------------------------------------------- /frontend/app.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 44 | -------------------------------------------------------------------------------- /frontend/components/Breadcrumb.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 47 | -------------------------------------------------------------------------------- /frontend/components/MessageModal.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 44 | -------------------------------------------------------------------------------- /frontend/components/Navigation.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 42 | -------------------------------------------------------------------------------- /frontend/components/RoleSelect.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | -------------------------------------------------------------------------------- /frontend/composables/useAPI.ts: -------------------------------------------------------------------------------- 1 | import type { UseFetchOptions } from 'nuxt/app' 2 | 3 | export default function ( 4 | url: string | (() => string), 5 | options: UseFetchOptions = {}, 6 | ) { 7 | return useFetch(url, { 8 | ...options, 9 | $fetch: useNuxtApp().$api as typeof $fetch, 10 | dedupe: 'defer', 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /frontend/composables/useAuth.ts: -------------------------------------------------------------------------------- 1 | export type AuthInfo = Omit 2 | 3 | export default () => { 4 | return useAPI('/auth') 5 | } 6 | -------------------------------------------------------------------------------- /frontend/composables/useMessages.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | id: string 3 | date: string 4 | author: string 5 | text: string 6 | } 7 | 8 | // FIXME: See if this could be simplified using advice from https://nuxt.com/docs/getting-started/data-fetching#consuming-sse-server-sent-events-via-post-request 9 | export default async function() { 10 | const { $api } = useNuxtApp() 11 | const messages = ref([]) 12 | let stream: ReadableStream 13 | let reader: ReadableStreamDefaultReader 14 | 15 | onMounted(async () => { 16 | stream = await $api('/messages', { 17 | responseType: 'stream', 18 | }) 19 | reader = stream.getReader() 20 | const decoder = new TextDecoder() 21 | const DATA_PREFIX = 'data: ' 22 | 23 | while (true) { 24 | let result: ReadableStreamReadResult 25 | 26 | try { 27 | result = await reader.read() 28 | } 29 | catch(error) { 30 | if ( 31 | !(error instanceof TypeError) 32 | || error.message !== 'Releasing lock' 33 | ) { 34 | reader.releaseLock() 35 | } 36 | break 37 | } 38 | 39 | if (result.done) { 40 | reader.releaseLock() 41 | break 42 | } 43 | 44 | const data = decoder.decode(result.value) 45 | 46 | for (const line of data.split('\n')) { 47 | if (!line.startsWith(DATA_PREFIX)) { 48 | continue 49 | } 50 | 51 | messages.value.push(JSON.parse(line.slice(DATA_PREFIX.length))) 52 | } 53 | } 54 | }) 55 | 56 | onUnmounted(async () => { 57 | reader?.releaseLock() 58 | await stream?.cancel() 59 | }) 60 | 61 | return { 62 | data: messages 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /frontend/composables/useUsers.ts: -------------------------------------------------------------------------------- 1 | export const ROLES = [ 2 | 'administrator', 3 | 'user', 4 | ] as const 5 | export type Role = typeof ROLES[number] 6 | 7 | export const ROLE_NAMES: Record = { 8 | administrator: 'Administrator', 9 | user: 'User', 10 | } 11 | 12 | export interface User { 13 | id: string 14 | creation_date: string 15 | email: string 16 | first_name: string 17 | last_name: string 18 | role: Role 19 | enabled: boolean 20 | } 21 | 22 | export default async function() { 23 | return useAPI('/users', { 24 | transform: (data: { users: User[] }) => data.users, 25 | } as { transform: (data: any) => User[] }) 26 | } 27 | 28 | export async function useUser(id: User['id']) { 29 | return useAPI(`/users/${id}`) 30 | } 31 | -------------------------------------------------------------------------------- /frontend/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | socket_gid=${SOCKET_GID:-0} 4 | 5 | ( 6 | rm -f /run/nuxt.sock && 7 | until [ -S /run/nuxt.sock ]; 8 | do 9 | sleep 1; 10 | done; 11 | chgrp "$socket_gid" /run/nuxt.sock && 12 | chmod g+w /run/nuxt.sock; 13 | ) & 14 | 15 | exec "$@" 16 | -------------------------------------------------------------------------------- /frontend/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async (to) => { 2 | const { data: authInfo } = await useAuth() 3 | const user = toValue(authInfo) 4 | 5 | if (!user) { 6 | return navigateTo('/login') 7 | } 8 | 9 | if (to.path.startsWith('/admin/') && user.role !== 'administrator') { 10 | throw createError({ 11 | status: 403, 12 | message: 'You are not allowed to view this page.', 13 | fatal: true, 14 | }) 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /frontend/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | icon: { 4 | localApiEndpoint: '/_nuxt_icon_api', 5 | }, 6 | modules: ['@nuxt/ui'], 7 | vite: { 8 | server: { 9 | allowedHosts: ['frontend'], 10 | }, 11 | }, 12 | compatibilityDate: '2025-04-01', 13 | }) 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "devDependencies": { 13 | "@nuxt/ui": "^2.21.1", 14 | "@vue/typescript-plugin": "^2.2.8", 15 | "nuxt": "^3.16.2", 16 | "typescript": "^5.8.3", 17 | "vue": "^3.5.13", 18 | "vue-router": "^4.5.0" 19 | }, 20 | "dependencies": { 21 | "zod": "^3.24.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/pages/admin/users/edit/[id].vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 125 | -------------------------------------------------------------------------------- /frontend/pages/admin/users/index.vue: -------------------------------------------------------------------------------- 1 | 161 | 162 | 303 | -------------------------------------------------------------------------------- /frontend/pages/admin/users/new.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 144 | -------------------------------------------------------------------------------- /frontend/pages/index.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 130 | -------------------------------------------------------------------------------- /frontend/pages/login.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 115 | -------------------------------------------------------------------------------- /frontend/pages/reset-password/[token].vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 130 | -------------------------------------------------------------------------------- /frontend/pages/reset-password/index.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 108 | -------------------------------------------------------------------------------- /frontend/pages/set-password/[token].vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 123 | -------------------------------------------------------------------------------- /frontend/plugins/api.ts: -------------------------------------------------------------------------------- 1 | declare module '#app' { 2 | interface NuxtApp { 3 | $api: typeof $fetch 4 | } 5 | } 6 | 7 | declare module 'vue' { 8 | interface ComponentCustomProperties { 9 | $api: typeof $fetch 10 | } 11 | } 12 | 13 | export default defineNuxtPlugin(() => { 14 | const config = useRuntimeConfig() 15 | const headers = useRequestHeaders(['cookie']) 16 | 17 | const $api = $fetch.create({ 18 | baseURL: config.app.baseURL + '/api', 19 | headers, 20 | }) 21 | return { provide: { api: $api } } 22 | }) 23 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cheaterman/fluxt/f2ee9636054ecb6db00a6a6eca087dd0af037e89/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/server/middleware/api.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | if (!event.path.startsWith('/api/')) { 3 | return 4 | } 5 | const { req } = event.node 6 | const target = `http://proxy${req.originalUrl}` 7 | return proxyRequest(event, target) 8 | }) 9 | -------------------------------------------------------------------------------- /frontend/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json", 3 | "compilerOptions": { 4 | "plugins": [{ "name": "@vue/typescript-plugin" }] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/utils/auth.ts: -------------------------------------------------------------------------------- 1 | export async function login( 2 | { username, password, rememberMe }: 3 | { username: string, password: string, rememberMe: boolean } 4 | ) { 5 | const { $api } = useNuxtApp() 6 | return $api('/auth', { headers: { 7 | 'Authorization': 'Basic ' + btoa(`${username}:${password}`), 8 | 'Fluxt-Remember-Me': rememberMe.toString(), 9 | }}) 10 | } 11 | 12 | export async function logout() { 13 | const { $api } = useNuxtApp() 14 | return $api<''>('/deauth') 15 | } 16 | -------------------------------------------------------------------------------- /frontend/utils/messages.ts: -------------------------------------------------------------------------------- 1 | export async function sendMessage(message: string) { 2 | const { $api } = useNuxtApp() 3 | return $api<{ id: string }>('/messages', { 4 | method: 'POST', 5 | body: { text: message }, 6 | }) 7 | } 8 | 9 | export async function deleteMessage(id: string) { 10 | const { $api } = useNuxtApp() 11 | return $api<''>(`/messages/${id}`, { method: 'DELETE' }) 12 | } 13 | -------------------------------------------------------------------------------- /frontend/utils/users.ts: -------------------------------------------------------------------------------- 1 | export async function createUser( 2 | user: Omit 3 | ) { 4 | const { $api } = useNuxtApp() 5 | return $api<{ id: string }>('/users', { 6 | method: 'POST', 7 | body: user, 8 | }) 9 | } 10 | 11 | export async function sendCreatedEmail(id: User['id']) { 12 | const { $api } = useNuxtApp() 13 | return $api<''>(`/users/${id}/send-created-email`, { method: 'POST' }) 14 | } 15 | 16 | export async function getPasswordState(token: string) { 17 | const { $api } = useNuxtApp() 18 | return $api<''>(`/set-password/${token}`) 19 | } 20 | 21 | export async function setPassword(token: string, password: string) { 22 | const { $api } = useNuxtApp() 23 | return $api<''>(`/set-password/${token}`, { 24 | method: 'POST', 25 | body: { password }, 26 | }) 27 | } 28 | 29 | export async function editUser( 30 | id: User['id'], 31 | user: Partial>, 32 | ) { 33 | const { $api } = useNuxtApp() 34 | return $api(`/users/${id}`, { 35 | method: 'PUT', 36 | body: user, 37 | }) 38 | } 39 | 40 | export async function sendResetPasswordEmail(email: User['email']) { 41 | const { $api } = useNuxtApp() 42 | return $api(`/reset-password/${email}`) 43 | } 44 | 45 | export async function resetPassword(token: string, password: string) { 46 | const { $api } = useNuxtApp() 47 | return $api(`/reset-password/${token}`, { 48 | method: 'POST', 49 | body: { password }, 50 | }) 51 | } 52 | 53 | export async function deleteUser(id: string) { 54 | const { $api } = useNuxtApp() 55 | return $api<''>(`/users/${id}`, { method: 'DELETE' }) 56 | } 57 | -------------------------------------------------------------------------------- /nginx.conf.template: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1000; 3 | } 4 | 5 | http { 6 | server { 7 | client_max_body_size 10M; 8 | listen 0.0.0.0:80; 9 | listen unix:/run/nginx.sock; 10 | location $BASE_URI/api { 11 | uwsgi_param SCRIPT_NAME $BASE_URI/api; 12 | uwsgi_pass unix:/backend_run/wsgi.sock; 13 | uwsgi_buffering off; 14 | include uwsgi_params; 15 | } 16 | location $BASE_URI/ { 17 | proxy_pass http://frontend:3000; 18 | proxy_set_header Upgrade $http_upgrade; 19 | proxy_set_header Connection "Upgrade"; 20 | proxy_read_timeout 3600s; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /start_devserver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | docker compose up -d && 3 | ( 4 | sleep .25 && 5 | echo -e "\n\n\t\e[32m✔\e[0m Fluxt is listening on http://0.0.0.0:8080\n\n" & 6 | ) && 7 | uwsgi \ 8 | --master \ 9 | --plugin http \ 10 | --plugin router_http \ 11 | --http :8080 \ 12 | --route '.* http:proxy_run/nginx.sock' \ 13 | -z 3600 \ 14 | --http-timeout 3600 \ 15 | --workers 4 \ 16 | --threads 64 \ 17 | --ignore-sigpipe \ 18 | --ignore-write-errors \ 19 | ; 20 | --------------------------------------------------------------------------------