('/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/frontend/components/MessageModal.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
11 | {{ title }}
12 |
13 |
14 |
15 |
16 | {{ content }}
17 |
18 |
19 |
20 |
21 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
44 |
--------------------------------------------------------------------------------
/frontend/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
44 |
--------------------------------------------------------------------------------
/frontend/components/Navigation.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
42 |
--------------------------------------------------------------------------------
/frontend/components/Breadcrumb.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
47 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/__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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/frontend/pages/reset-password/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 | Please input your e-mail to reset your password
7 |
8 |
15 |
20 |
23 |
24 |
27 |
33 |
37 |
38 |
39 |
40 |
44 |
45 |
46 |
47 |
108 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/pages/login.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 | Login
7 |
8 |
15 |
20 |
23 |
24 |
29 |
33 |
34 |
38 |
42 | Forgotten password
43 |
44 |
47 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
115 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/pages/set-password/[token].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 | Set your password
7 |
8 |
15 |
20 |
24 |
25 |
26 |
29 |
33 |
34 |
35 |
39 |
40 |
41 |
42 |
123 |
--------------------------------------------------------------------------------
/frontend/pages/admin/users/edit/[id].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
12 |
15 | Edit user
16 |
17 |
18 |
25 |
30 |
33 |
34 |
39 |
42 |
43 |
48 |
51 |
52 |
53 |
56 |
62 |
66 |
67 |
68 |
69 |
70 |
71 |
125 |
--------------------------------------------------------------------------------
/frontend/pages/reset-password/[token].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 | Reset your password
7 |
8 |
15 |
20 |
24 |
25 |
26 |
29 |
33 |
34 |
35 |
39 |
40 |
41 |
42 |
130 |
--------------------------------------------------------------------------------
/frontend/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 | Messages
7 |
8 |
29 |
37 |
41 |
42 |
43 |
47 |
48 |
49 |
50 |
51 |
130 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/frontend/pages/admin/users/new.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
12 |
15 | New user
16 |
17 |
18 |
25 |
30 |
33 |
34 |
39 |
42 |
43 |
48 |
51 |
52 |
57 |
60 |
61 |
62 |
65 |
71 |
75 |
76 |
77 |
78 |
79 |
80 |
144 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/frontend/pages/admin/users/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
9 | Users
10 |
11 |
16 |
17 |
24 |
33 |
34 |
35 | {{ user.email }}
36 |
43 |
44 |
45 |
46 |
47 |
51 |
52 |
53 |
54 | {{ user.enabled ? 'On' : 'Off' }}
55 |
56 |
57 |
58 |
59 |
65 |
70 |
76 |
77 |
78 |
79 |
82 |
83 |
84 |
87 | Confirm user deletion
88 |
89 |
90 |
91 |
92 | Are you sure you want to delete user
93 | {{ userBeingDeleted?.first_name }}
94 | {{ userBeingDeleted?.last_name }}?
95 |
96 |
97 |
98 |
99 |
105 |
110 |
111 |
112 |
113 |
114 |
117 |
118 |
119 |
122 | Confirm resend initialization email
123 |
124 |
125 |
126 |
127 | Are you sure you want to resend the initialization email to
128 | {{ userBeingResentEmail?.email }}?
129 |
130 |
131 | The reset email has been successfully sent to
132 | {{ userBeingResentEmail?.email }}
133 |
134 |
135 |
136 |
137 |
143 |
147 |
148 |
149 |
150 |
151 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
303 |
--------------------------------------------------------------------------------