Compare commits

...

9 Commits

Author SHA1 Message Date
8f4ac8a7f5
Update server_endpoints.html 2025-06-04 19:47:27 +10:00
e311d15299
Update app.py 2025-06-04 19:43:37 +10:00
df79ad312e
Update app.py 2025-06-04 19:34:22 +10:00
4d712b64f2
Merge pull request #1 from Sudo-JHare/well-known-updates
.well-known updates
2025-05-26 16:18:20 +10:00
c0f16c8971 .well-known updates
updated UI to show .well-known config in server and client pages.
2025-05-26 16:17:39 +10:00
29570e1b9a
Update README.md 2025-05-07 13:43:43 +10:00
25ad57063b Merge branch 'main' of https://github.com/Sudo-JHare/FHIREVINE-Smart-Proxy 2025-04-24 10:34:49 +10:00
3fca71d543 initial setup files. 2025-04-24 10:34:31 +10:00
ec8d76158c
Update issue templates 2025-04-24 09:40:23 +10:00
24 changed files with 631 additions and 101 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -1,4 +1,5 @@
# FHIRVINE SMART Proxy # FHIRVINE SMART Proxy
![FHIRFLARE Logo](static/FHIRVINE_LOGO.png)
## Overview ## Overview
@ -116,4 +117,4 @@ This project is licensed under the MIT License. See the `LICENSE` file for detai
## Contact ## Contact
For support or inquiries, contact the development team at `support@fhirvine.example.com`. For support or inquiries, contact the development team at `support@fhirvine.example.com`.

169
app.py
View File

@ -17,6 +17,7 @@ from sqlalchemy.exc import OperationalError
from sqlalchemy import text from sqlalchemy import text
from wtforms import StringField, URLField, SubmitField from wtforms import StringField, URLField, SubmitField
from wtforms.validators import DataRequired, URL from wtforms.validators import DataRequired, URL
from werkzeug.middleware.proxy_fix import ProxyFix
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@ -33,6 +34,7 @@ migrate = Migrate()
def create_app(): def create_app():
app = Flask(__name__, instance_relative_config=True) app = Flask(__name__, instance_relative_config=True)
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1, x_for=1)
app.config.from_mapping( app.config.from_mapping(
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev-secret-key-for-fhirvine'), SECRET_KEY=os.environ.get('SECRET_KEY', 'dev-secret-key-for-fhirvine'),
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', f'sqlite:///{os.path.join(app.instance_path, "fhirvine.db")}'), SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', f'sqlite:///{os.path.join(app.instance_path, "fhirvine.db")}'),
@ -71,7 +73,7 @@ def create_app():
}, },
"host": "localhost:5001", "host": "localhost:5001",
"basePath": "/oauth2", "basePath": "/oauth2",
"schemes": ["http"] "schemes": ["https"]
} }
swagger_config = { swagger_config = {
"specs": [ "specs": [
@ -106,18 +108,15 @@ def create_app():
def get_config_value(key, default): def get_config_value(key, default):
try: try:
# Ensure the session is fresh
database.session.expire_all() database.session.expire_all()
config = Configuration.query.filter_by(key=key).first() config = Configuration.query.filter_by(key=key).first()
if config: if config:
logger.debug(f"Retrieved {key} from database (exact match): {config.value}") logger.debug(f"Retrieved {key} from database (exact match): {config.value}")
return int(config.value) if key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else config.value return int(config.value) if key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else config.value
# Try case-insensitive match
config = Configuration.query.filter(Configuration.key.ilike(key)).first() config = Configuration.query.filter(Configuration.key.ilike(key)).first()
if config: if config:
logger.debug(f"Retrieved {key} from database (case-insensitive match): {config.value}") logger.debug(f"Retrieved {key} from database (case-insensitive match): {config.value}")
return int(config.value) if key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else config.value return int(config.value) if key in ['TOKEN_DURATION', 'PROXY_TIMEOUT', 'REFRESH_TOKEN_DURATION'] else config.value
# Fallback: Direct database query
result = database.session.execute( result = database.session.execute(
text("SELECT value FROM configurations WHERE key = :key"), text("SELECT value FROM configurations WHERE key = :key"),
{"key": key} {"key": key}
@ -235,6 +234,23 @@ def create_app():
response_data = None response_data = None
response_mode = session.get('response_mode', 'inline') response_mode = session.get('response_mode', 'inline')
logger.debug(f"Session contents before form submission: {session}") logger.debug(f"Session contents before form submission: {session}")
try:
smart_config_url = url_for('smart_proxy.smart_configuration', _external=True)
response = requests.get(smart_config_url)
response.raise_for_status()
smart_config = response.json()
logger.debug(f"SMART configuration for test client: {smart_config}")
except requests.RequestException as e:
logger.error(f"Error fetching SMART configuration: {e}")
flash(f"Error fetching SMART configuration: {e}", "error")
smart_config = {
'authorization_endpoint': url_for('smart_proxy.authorize', _external=True),
'scopes_supported': current_app.config['ALLOWED_SCOPES'].split(),
'response_types_supported': ['code'],
'code_challenge_methods_supported': ['S256']
}
if form.validate_on_submit(): if form.validate_on_submit():
client_id = form.client_id.data client_id = form.client_id.data
app = RegisteredApp.query.filter_by(client_id=client_id).first() app = RegisteredApp.query.filter_by(client_id=client_id).first()
@ -243,29 +259,35 @@ def create_app():
return redirect(url_for('test_client')) return redirect(url_for('test_client'))
response_mode = form.response_mode.data if hasattr(form, 'response_mode') and form.response_mode.data else 'inline' response_mode = form.response_mode.data if hasattr(form, 'response_mode') and form.response_mode.data else 'inline'
session['response_mode'] = response_mode session['response_mode'] = response_mode
scopes = ' '.join(set(app.scopes.split()))
app_scopes = set(app.scopes.split())
supported_scopes = set(smart_config.get('scopes_supported', []))
scopes = ' '.join(app_scopes.intersection(supported_scopes))
if not scopes:
flash("No valid scopes available for this client.", "error")
return redirect(url_for('test_client'))
code_verifier = secrets.token_urlsafe(32) code_verifier = secrets.token_urlsafe(32)
code_challenge = base64.urlsafe_b64encode( code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode('ascii')).digest() hashlib.sha256(code_verifier.encode('ascii')).digest()
).decode('ascii').rstrip('=') ).decode('ascii').rstrip('=')
session['code_verifier'] = code_verifier session['code_verifier'] = code_verifier
redirect_uri = app.get_default_redirect_uri().lower() redirect_uri = app.get_default_redirect_uri().lower()
auth_url = url_for( auth_url = smart_config.get('authorization_endpoint', url_for('smart_proxy.authorize', _external=True))
'smart_proxy.authorize', auth_params = {
client_id=client_id, 'client_id': client_id,
redirect_uri=redirect_uri, 'redirect_uri': redirect_uri,
scope=scopes, 'scope': scopes,
response_type='code', 'response_type': 'code',
state='test_state_123', 'state': 'test_state_123',
aud=current_app.config['FHIR_SERVER_URL'] + current_app.config['METADATA_ENDPOINT'], 'aud': current_app.config['FHIR_SERVER_URL'] + current_app.config['METADATA_ENDPOINT'],
code_challenge=code_challenge, 'code_challenge': code_challenge,
code_challenge_method='S256', 'code_challenge_method': 'S256'
_external=True }
) logger.info(f"Constructing auth URL: {auth_url} with params: {auth_params}")
logger.info(f"Redirecting to authorization URL for client '{client_id}'")
logger.info(f"Code verifier for client '{client_id}': {code_verifier}")
session['post_auth_redirect'] = redirect_uri session['post_auth_redirect'] = redirect_uri
return redirect(auth_url) return redirect(f"{auth_url}?{'&'.join(f'{k}={v}' for k, v in auth_params.items())}")
if 'code' in request.args and 'state' in request.args: if 'code' in request.args and 'state' in request.args:
code = request.args.get('code') code = request.args.get('code')
state = request.args.get('state') state = request.args.get('state')
@ -288,6 +310,7 @@ def create_app():
finally: finally:
session.pop('post_auth_redirect', None) session.pop('post_auth_redirect', None)
session.pop('response_mode', None) session.pop('response_mode', None)
try: try:
apps = RegisteredApp.query.filter( apps = RegisteredApp.query.filter(
(RegisteredApp.is_test_app == False) | (RegisteredApp.is_test_app == False) |
@ -299,7 +322,8 @@ def create_app():
logger.error(f"Error fetching apps for test client: {e}", exc_info=True) logger.error(f"Error fetching apps for test client: {e}", exc_info=True)
flash("Could not load applications. Please try again later.", "error") flash("Could not load applications. Please try again later.", "error")
apps = [] apps = []
return render_template('test_client.html', form=form, apps=apps, response_data=response_data, response_mode=response_mode)
return render_template('test_client.html', form=form, apps=apps, response_data=response_data, response_mode=response_mode, smart_config=smart_config)
@app.route('/test-server', methods=['GET', 'POST']) @app.route('/test-server', methods=['GET', 'POST'])
def test_server(): def test_server():
@ -312,42 +336,76 @@ def create_app():
form = ServerTestForm() form = ServerTestForm()
response_data = None response_data = None
try:
smart_config_url = url_for('smart_proxy.smart_configuration', _external=True)
response = requests.get(smart_config_url)
response.raise_for_status()
smart_config = response.json()
logger.debug(f"SMART configuration for test server: {smart_config}")
except requests.RequestException as e:
logger.error(f"Error fetching SMART configuration: {e}")
flash(f"Error fetching SMART configuration: {e}", "error")
smart_config = {
'authorization_endpoint': url_for('smart_proxy.authorize', _external=True),
'scopes_supported': current_app.config['ALLOWED_SCOPES'].split(),
'response_types_supported': ['code'],
'code_challenge_methods_supported': ['S256']
}
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
client_id = form.client_id.data client_id = form.client_id.data
client_secret = form.client_secret.data client_secret = form.client_secret.data
redirect_uri = form.redirect_uri.data redirect_uri = form.redirect_uri.data
scopes = ' '.join(set(form.scopes.data.split()))
requested_scopes = set(form.scopes.data.split())
supported_scopes = set(smart_config.get('scopes_supported', []))
valid_scopes = requested_scopes.intersection(supported_scopes)
if not valid_scopes:
flash("No valid scopes provided.", "error")
return render_template('test_server.html', form=form, response_data=response_data, smart_config=smart_config)
scopes = ' '.join(valid_scopes)
app = RegisteredApp.query.filter_by(client_id=client_id).first()
if not app or not app.check_client_secret(client_secret):
flash("Invalid client ID or secret.", "error")
return render_template('test_server.html', form=form, response_data=response_data, smart_config=smart_config)
if not app.check_redirect_uri(redirect_uri):
flash("Invalid redirect URI for this client.", "error")
return render_template('test_server.html', form=form, response_data=response_data, smart_config=smart_config)
code_verifier = secrets.token_urlsafe(32) code_verifier = secrets.token_urlsafe(32)
code_challenge = base64.urlsafe_b64encode( code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode('ascii')).digest() hashlib.sha256(code_verifier.encode('ascii')).digest()
).decode('ascii').rstrip('=') ).decode('ascii').rstrip('=')
session['server_test_code_verifier'] = code_verifier session['server_test_code_verifier'] = code_verifier
session['server_test_client_secret'] = client_secret session['server_test_client_secret'] = client_secret
auth_url = url_for( auth_url = smart_config.get('authorization_endpoint', url_for('smart_proxy.authorize', _external=True))
'smart_proxy.authorize', auth_params = {
client_id=client_id, 'client_id': client_id,
redirect_uri=redirect_uri, 'redirect_uri': redirect_uri,
scope=scopes, 'scope': scopes,
response_type='code', 'response_type': 'code',
state='server_test_state', 'state': 'server_test_state',
aud=current_app.config['FHIR_SERVER_URL'] + current_app.config['METADATA_ENDPOINT'], 'aud': current_app.config['FHIR_SERVER_URL'] + current_app.config['METADATA_ENDPOINT'],
code_challenge=code_challenge, 'code_challenge': code_challenge,
code_challenge_method='S256', 'code_challenge_method': 'S256'
_external=True }
)
session['server_test_redirect_uri'] = redirect_uri session['server_test_redirect_uri'] = redirect_uri
return redirect(auth_url) return redirect(f"{auth_url}?{'&'.join(f'{k}={v}' for k, v in auth_params.items())}")
except Exception as e: except Exception as e:
flash(f"Error initiating authorization: {e}", "error") flash(f"Error initiating authorization: {e}", "error")
logger.error(f"Error in test-server: {e}", exc_info=True) logger.error(f"Error in test-server: {e}", exc_info=True)
if 'code' in request.args and 'state' in request.args and request.args.get('state') == 'server_test_state': if 'code' in request.args and 'state' in request.args and request.args.get('state') == 'server_test_state':
code = request.args.get('code') code = request.args.get('code')
redirect_uri = session.get('server_test_redirect_uri') redirect_uri = session.get('server_test_redirect_uri')
client_secret = session.get('server_test_client_secret') client_secret = session.get('server_test_client_secret')
code_verifier = session.get('server_test_code_verifier') code_verifier = session.get('server_test_code_verifier')
if redirect_uri and client_secret and code_verifier: if redirect_uri and client_secret and code_verifier:
token_url = url_for('smart_proxy.issue_token', _external=True) token_url = smart_config.get('token_endpoint', url_for('smart_proxy.issue_token', _external=True))
token_data = { token_data = {
'grant_type': 'authorization_code', 'grant_type': 'authorization_code',
'code': code, 'code': code,
@ -369,7 +427,8 @@ def create_app():
session.pop('server_test_redirect_uri', None) session.pop('server_test_redirect_uri', None)
session.pop('server_test_client_secret', None) session.pop('server_test_client_secret', None)
session.pop('server_test_code_verifier', None) session.pop('server_test_code_verifier', None)
return render_template('test_server.html', form=form, response_data=response_data)
return render_template('test_server.html', form=form, response_data=response_data, smart_config=smart_config)
@app.route('/configure/security', methods=['GET', 'POST']) @app.route('/configure/security', methods=['GET', 'POST'])
def security_settings(): def security_settings():
@ -382,14 +441,10 @@ def create_app():
'refresh_token_duration': refresh_token_duration, 'refresh_token_duration': refresh_token_duration,
'allowed_scopes': allowed_scopes 'allowed_scopes': allowed_scopes
} }
logger.debug(f"Security form data: {form_data}")
form.process(data=form_data) form.process(data=form_data)
# Fallback: Directly set form field values
form.token_duration.data = token_duration form.token_duration.data = token_duration
form.refresh_token_duration.data = refresh_token_duration form.refresh_token_duration.data = refresh_token_duration
form.allowed_scopes.data = allowed_scopes form.allowed_scopes.data = allowed_scopes
logger.debug(f"Security form fields after process: {list(form._fields.keys())}")
logger.debug(f"Security form values before render: token_duration={form.token_duration.data}, refresh_token_duration={form.refresh_token_duration.data}, allowed_scopes={form.allowed_scopes.data}")
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
configs = [ configs = [
@ -423,13 +478,9 @@ def create_app():
'fhir_server_url': fhir_server_url, 'fhir_server_url': fhir_server_url,
'proxy_timeout': proxy_timeout 'proxy_timeout': proxy_timeout
} }
logger.debug(f"Proxy settings form data: {form_data}")
form.process(data=form_data) form.process(data=form_data)
# Fallback: Directly set form field values
form.fhir_server_url.data = fhir_server_url form.fhir_server_url.data = fhir_server_url
form.proxy_timeout.data = proxy_timeout form.proxy_timeout.data = proxy_timeout
logger.debug(f"Proxy settings form fields after process: {list(form._fields.keys())}")
logger.debug(f"Proxy settings form values before render: fhir_server_url={form.fhir_server_url.data}, proxy_timeout={form.proxy_timeout.data}")
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
configs = [ configs = [
@ -451,7 +502,7 @@ def create_app():
database.session.rollback() database.session.rollback()
logger.error(f"Error updating proxy settings: {e}", exc_info=True) logger.error(f"Error updating proxy settings: {e}", exc_info=True)
flash(f"Error updating proxy settings: {e}", "error") flash(f"Error updating proxy settings: {e}", "error")
return render_template('configure/proxy_settings.html', form=form, fhir_server_url=app.config['FHIR_SERVER_URL']) return render_template('configure/proxy_settings.html', form=form)
@app.route('/configure/server-endpoints', methods=['GET', 'POST']) @app.route('/configure/server-endpoints', methods=['GET', 'POST'])
def server_endpoints(): def server_endpoints():
@ -464,14 +515,10 @@ def create_app():
'capability_endpoint': capability_endpoint, 'capability_endpoint': capability_endpoint,
'resource_base_endpoint': resource_base_endpoint 'resource_base_endpoint': resource_base_endpoint
} }
logger.debug(f"Server endpoints form data: {form_data}")
form.process(data=form_data) form.process(data=form_data)
# Fallback: Directly set form field values
form.metadata_endpoint.data = metadata_endpoint form.metadata_endpoint.data = metadata_endpoint
form.capability_endpoint.data = capability_endpoint form.capability_endpoint.data = capability_endpoint
form.resource_base_endpoint.data = resource_base_endpoint form.resource_base_endpoint.data = resource_base_endpoint
logger.debug(f"Server endpoints form fields after process: {list(form._fields.keys())}")
logger.debug(f"Server endpoints form values before render: metadata_endpoint={form.metadata_endpoint.data}, capability_endpoint={form.capability_endpoint.data}, resource_base_endpoint={form.resource_base_endpoint.data}")
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
configs = [ configs = [
@ -508,6 +555,20 @@ def create_app():
flash(f"Error updating endpoint settings: {e}", "error") flash(f"Error updating endpoint settings: {e}", "error")
return render_template('configure/server_endpoints.html', form=form) return render_template('configure/server_endpoints.html', form=form)
@app.route('/test-smart-config', methods=['GET'])
def test_smart_config():
try:
smart_config_url = url_for('smart_proxy.smart_configuration', _external=True)
response = requests.get(smart_config_url)
response.raise_for_status()
config_data = response.json()
logger.debug(f"SMART configuration fetched: {config_data}")
return render_template('test_smart_config.html', config_data=config_data)
except requests.RequestException as e:
logger.error(f"Error fetching SMART configuration: {e}")
flash(f"Error fetching SMART configuration: {e}", "error")
return render_template('test_smart_config.html', config_data=None)
@app.route('/api-docs') @app.route('/api-docs')
@swag_from({ @swag_from({
'tags': ['Documentation'], 'tags': ['Documentation'],
@ -520,6 +581,8 @@ def create_app():
} }
}) })
def custom_apidocs(): def custom_apidocs():
spec_url = url_for('flasgger.apispec_1', _external=True)
logger.debug(f"Swagger spec URL: {spec_url}")
return render_template('swagger-ui.html') return render_template('swagger-ui.html')
@app.route('/about') @app.route('/about')
@ -539,4 +602,8 @@ def create_app():
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
logger.info("FHIRVINE Flask application created and configured.") logger.info("FHIRVINE Flask application created and configured.")
return app return app
if __name__ == '__main__':
app = create_app()
app.run(debug=True, port=5001)

View File

@ -1,4 +1,4 @@
# docker-compose.yml for FHIRVINE version: '3.8'
services: services:
fhirvine: fhirvine:
@ -9,13 +9,23 @@ services:
volumes: volumes:
- fhirvine_instance:/app/instance - fhirvine_instance:/app/instance
- fhirvine_migrations:/app/migrations - fhirvine_migrations:/app/migrations
- ./setup/migrations:/app/setup_migrations
- ./setup/fhirvine.db:/app/setup_fhirvine.db
- ./static:/app/static
- ./templates:/app/templates
environment: environment:
- FLASK_ENV=${FLASK_ENV:-development} - FLASK_ENV=${FLASK_ENV:-development}
env_file: env_file:
- .env - .env
command: > command: >
sh -c "flask db upgrade && waitress-serve --host=0.0.0.0 --port=5001 --call 'app:create_app'" sh -c "echo 'Checking for setup migrations directory...' && ls -la /app/setup_migrations && if [ ! -d /app/migrations ]; then echo 'Copying migrations directory...' && cp -rv /app/setup_migrations /app/migrations && chown -R 1000:1000 /app/migrations && ls -la /app/migrations || { echo 'Failed to copy migrations'; exit 1; }; fi && if [ ! -f /app/instance/fhirvine.db ]; then echo 'Copying database file...' && cp -v /app/setup_fhirvine.db /app/instance/fhirvine.db && chown -R 1000:1000 /app/instance && ls -la /app/instance || { echo 'Failed to copy database'; exit 1; }; fi && echo 'Starting application...' && waitress-serve --host=0.0.0.0 --port=5001 --call 'app:create_app'"
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5001/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes: volumes:
fhirvine_instance: fhirvine_instance:

BIN
setup/fhirvine.db Normal file

Binary file not shown.

1
setup/migrations/README Normal file
View File

@ -0,0 +1 @@
Single-database configuration for Flask.

View File

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
setup/migrations/env.py Normal file
View File

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,86 @@
"""Create initial schema after purge
Revision ID: 4e8d36d8e416
Revises:
Create Date: 2025-04-23 13:08:11.515425
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4e8d36d8e416'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('configurations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('value', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('last_updated', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key')
)
op.create_table('oauth_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('client_id', sa.String(length=100), nullable=False),
sa.Column('token_type', sa.String(length=40), nullable=True),
sa.Column('access_token', sa.String(length=255), nullable=False),
sa.Column('refresh_token', sa.String(length=255), nullable=True),
sa.Column('scope', sa.Text(), nullable=True),
sa.Column('issued_at', sa.Integer(), nullable=False),
sa.Column('expires_in', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('registered_apps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('app_name', sa.String(length=100), nullable=False),
sa.Column('client_id', sa.String(length=100), nullable=False),
sa.Column('client_secret_hash', sa.String(length=200), nullable=False),
sa.Column('redirect_uris', sa.Text(), nullable=False),
sa.Column('scopes', sa.Text(), nullable=False),
sa.Column('logo_uri', sa.String(length=255), nullable=True),
sa.Column('contacts', sa.Text(), nullable=True),
sa.Column('tos_uri', sa.String(length=255), nullable=True),
sa.Column('policy_uri', sa.String(length=255), nullable=True),
sa.Column('jwks_uri', sa.String(length=255), nullable=True),
sa.Column('date_registered', sa.DateTime(), nullable=True),
sa.Column('last_updated', sa.DateTime(), nullable=True),
sa.Column('is_test_app', sa.Boolean(), nullable=True),
sa.Column('test_app_expires_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('app_name'),
sa.UniqueConstraint('client_id')
)
op.create_table('authorization_codes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=255), nullable=False),
sa.Column('client_id', sa.String(length=100), nullable=False),
sa.Column('redirect_uri', sa.Text(), nullable=True),
sa.Column('scope', sa.Text(), nullable=True),
sa.Column('nonce', sa.String(length=255), nullable=True),
sa.Column('code_challenge', sa.String(length=255), nullable=True),
sa.Column('code_challenge_method', sa.String(length=10), nullable=True),
sa.Column('response_type', sa.String(length=40), nullable=True),
sa.Column('state', sa.String(length=255), nullable=True),
sa.Column('issued_at', sa.Integer(), nullable=False),
sa.Column('expires_at', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['registered_apps.client_id'], name='fk_authorization_codes_client_id'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('authorization_codes')
op.drop_table('registered_apps')
op.drop_table('oauth_tokens')
op.drop_table('configurations')
# ### end Alembic commands ###

View File

@ -3,22 +3,27 @@
{% block title %}About - {{ site_name }}{% endblock %} {% block title %}About - {{ site_name }}{% endblock %}
{% block content %} {% block content %}
<div class="container mt-5"> <div class="container mt-4">
<h1 class="text-center mb-4">About FHIRVINE SMART Proxy</h1>
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-8"> <div class="col-md-8">
<h1 class="text-center mb-4">About {{ site_name }}</h1>
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<p>{{ site_name }} is a SMART on FHIR SSO proxy designed to facilitate secure app integration with FHIR servers.</p> <p>FHIRVINE is a modular SMART on FHIR Single Sign-On (SSO) Proxy designed to facilitate secure authentication and authorization for FHIR-based applications.</p>
<h5>Features</h5> <h5>Features</h5>
<ul> <ul>
<li>Secure OAuth2 authorization flows with PKCE support.</li> <li>Register and manage SMART on FHIR applications.</li>
<li>App gallery for managing registered applications.</li> <li>Test client and server-side OAuth2 flows.</li>
<li>Proxy FHIR API requests to backend servers.</li> <li>Configure proxy settings and FHIR server endpoints.</li>
<li>Light and dark theme support.</li> <li>Support for PKCE and secure token management.</li>
</ul> </ul>
<p>Built with Flask, Bootstrap, and Authlib. Learn more about SMART on FHIR at <a href="https://www.hl7.org/fhir/smart-app-launch/" target="_blank">HL7 SMART on FHIR</a>.</p> <h5>Developer</h5>
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Home</a> <p>Developed by <a href="https://github.com/Sudo-JHare">Sudo-JHare</a>. Source code available on <a href="https://github.com/Sudo-JHare/FHIRVINE-Smart-Proxy">GitHub</a>.</p>
<h5>Contact</h5>
<p>Report issues or contribute at <a href="https://github.com/Sudo-JHare/FHIRVINE-Smart-Proxy/issues">GitHub Issues</a>.</p>
<div class="text-center mt-4">
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Home</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -245,12 +245,12 @@
<div class="container-fluid"> <div class="container-fluid">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="footer-left"> <div class="footer-left">
<a href="https://github.com/Sudo-JHare/FHIRVINE" target="_blank" rel="noreferrer" aria-label="Project Github">FHIRVINE Github</a> <a href="https://github.com/Sudo-JHare/FHIREVINE-Smart-Proxy" target="_blank" rel="noreferrer" aria-label="Project Github">FHIRVINE Github</a>
<a href="{{ url_for('about') }}" aria-label="About FHIRVINE">About FHIRVINE</a> <a href="{{ url_for('about') }}" aria-label="About FHIRVINE">About FHIRVINE</a>
<a href="https://www.hl7.org/fhir/smart-app-launch/" target="_blank" rel="noreferrer" aria-label="SMART on FHIR">SMART on FHIR</a> <a href="https://www.hl7.org/fhir/smart-app-launch/" target="_blank" rel="noreferrer" aria-label="SMART on FHIR">SMART on FHIR</a>
</div> </div>
<div class="footer-right"> <div class="footer-right">
<a href="https://github.com/Sudo-JHare/FHIRVINE/issues" target="_blank" rel="noreferrer" aria-label="Report Issue">Report Issue</a> <a href="https://github.com/Sudo-JHare/FHIREVINE-Smart-Proxy/issues" target="_blank" rel="noreferrer" aria-label="Report Issue">Report Issue</a>
<a href="https://github.com/Sudo-JHare" aria-label="Developer">Developer</a> <a href="https://github.com/Sudo-JHare" aria-label="Developer">Developer</a>
</div> </div>
</div> </div>

View File

@ -15,6 +15,13 @@
<p class="text-muted text-center small mb-4"> <p class="text-muted text-center small mb-4">
Configure security settings for OAuth2 authentication. Configure security settings for OAuth2 authentication.
</p> </p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('security_settings') }}" novalidate> <form method="POST" action="{{ url_for('security_settings') }}" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ render_field(form.token_duration, value=form.token_duration.data) }} {{ render_field(form.token_duration, value=form.token_duration.data) }}

View File

@ -15,6 +15,13 @@
<p class="text-muted text-center small mb-4"> <p class="text-muted text-center small mb-4">
Configure endpoints for the upstream FHIR server and view available SMART endpoints. Configure endpoints for the upstream FHIR server and view available SMART endpoints.
</p> </p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('server_endpoints') }}" novalidate> <form method="POST" action="{{ url_for('server_endpoints') }}" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ render_field(form.metadata_endpoint) }} {{ render_field(form.metadata_endpoint) }}
@ -28,7 +35,7 @@
<hr class="my-4"> <hr class="my-4">
<h5>Available SMART Endpoints</h5> <h5>Available SMART Endpoints</h5>
<ul class="list-group mb-4"> <ul class="list-group mb-4">
<li class="list-group-item"><strong>Configuration:</strong> <code>/.well-known/smart-configuration</code> - SMART on FHIR discovery</li> <li class="list-group-item"><strong>Configuration:</strong> <code>/oauth2/.well-known/smart-configuration</code> - SMART on FHIR discovery</li>
<li class="list-group-item"><strong>Authorization:</strong> <code>/oauth2/authorize</code> - Initiate OAuth2 flow</li> <li class="list-group-item"><strong>Authorization:</strong> <code>/oauth2/authorize</code> - Initiate OAuth2 flow</li>
<li class="list-group-item"><strong>Token:</strong> <code>/oauth2/token</code> - Exchange code for token</li> <li class="list-group-item"><strong>Token:</strong> <code>/oauth2/token</code> - Exchange code for token</li>
<li class="list-group-item"><strong>Revoke:</strong> <code>/oauth2/revoke</code> - Revoke tokens</li> <li class="list-group-item"><strong>Revoke:</strong> <code>/oauth2/revoke</code> - Revoke tokens</li>

View File

@ -1,5 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Authorize Application - {{ site_name }}{% endblock %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@ -1,14 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}404 - Not Found - {{ site_name }}{% endblock %}
{% block content %} {% block content %}
<div class="container mt-5"> <div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-8 col-lg-6"> <div class="col-md-8">
<div class="alert alert-warning text-center" role="alert"> <div class="card shadow-sm">
<h4 class="alert-heading"><i class="bi bi-exclamation-triangle-fill me-2"></i>Page Not Found</h4> <div class="card-body text-center">
<p>The page you are looking for does not exist.</p> <h1 class="display-1 text-danger">404</h1>
<hr> <h2>Page Not Found</h2>
<a href="{{ url_for('index') }}" class="btn btn-secondary">Return Home</a> <p class="text-muted mb-4">The page you're looking for doesn't exist or has been moved.</p>
<a href="{{ url_for('index') }}" class="btn btn-primary">Return to Home</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,14 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}500 - Server Error - {{ site_name }}{% endblock %}
{% block content %} {% block content %}
<div class="container mt-5"> <div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-8 col-lg-6"> <div class="col-md-8">
<div class="alert alert-danger text-center" role="alert"> <div class="card shadow-sm">
<h4 class="alert-heading"><i class="bi bi-exclamation-triangle-fill me-2"></i>Internal Server Error</h4> <div class="card-body text-center">
<p>Something went wrong on our end. Please try again later.</p> <h1 class="display-1 text-danger">500</h1>
<hr> <h2>Internal Server Error</h2>
<a href="{{ url_for('index') }}" class="btn btn-secondary">Return Home</a> <p class="text-muted mb-4">Something went wrong on our end. Please try again later.</p>
<a href="{{ url_for('index') }}" class="btn btn-primary">Return to Home</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,20 +1,19 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Authentication Error - {{ site_name }}{% endblock %}
{% block content %} {% block content %}
<div class="container mt-5"> <div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-8 col-lg-6"> <div class="col-md-8">
<div class="alert alert-danger text-center" role="alert"> <div class="card shadow-sm">
<h4 class="alert-heading"><i class="bi bi-exclamation-triangle-fill me-2"></i>Authorization Error</h4> <div class="card-body text-center">
<p>An error occurred during the authorization process.</p> <h2 class="text-danger">Authentication Error</h2>
<hr> <p class="text-muted mb-4">{{ error }}</p>
<p class="mb-0 small"><strong>Details:</strong> {{ error | e }}</p> {# Display the error message #} <a href="{{ url_for('index') }}" class="btn btn-primary">Return to Home</a>
</div>
</div> </div>
<div class="text-center mt-3">
<a href="{{ url_for('index') }}" class="btn btn-secondary">Return Home</a>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,18 +1,36 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_formhelpers.html" import render_field %}
{% block title %}Proxy Settings - {{ site_name }}{% endblock %} {% block title %}Proxy Settings - {{ site_name }}{% endblock %}
{% block content %} {% block content %}
<div class="container mt-5"> <div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-8"> <div class="col-md-8 col-lg-6">
<h1 class="text-center mb-4">Proxy Settings</h1>
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header">
<h2 class="text-center mb-0">Proxy Settings</h2>
</div>
<div class="card-body"> <div class="card-body">
<h5>FHIR Server URL</h5> <p class="text-muted text-center small mb-4">
<p class="text-muted">Current FHIR Server: <code>{{ fhir_server_url }}</code></p> Configure the upstream FHIR server and proxy settings.
<p class="text-warning">Editing the FHIR server URL requires environment variable changes. Please update <code>FHIR_SERVER_URL</code> in your <code>.env</code> file and restart the application.</p> </p>
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Home</a> {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('proxy_settings') }}" novalidate>
{{ form.hidden_tag() }}
{{ render_field(form.fhir_server_url, value=form.fhir_server_url.data) }}
{{ render_field(form.proxy_timeout, value=form.proxy_timeout.data) }}
<div class="d-grid gap-2 mt-4">
{{ form.submit(class="btn btn-primary btn-lg") }}
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary btn-lg">Cancel</a>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>

View File

@ -15,11 +15,11 @@
<li class="list-group-item"><strong>Token:</strong> <code>/oauth2/token</code></li> <li class="list-group-item"><strong>Token:</strong> <code>/oauth2/token</code></li>
<li class="list-group-item"><strong>FHIR Proxy:</strong> <code>/oauth2/proxy/&lt;path&gt;</code></li> <li class="list-group-item"><strong>FHIR Proxy:</strong> <code>/oauth2/proxy/&lt;path&gt;</code></li>
</ul> </ul>
<p class="mt-3"><a href="/apidocs" class="btn btn-primary">View API Documentation</a></p> <p class="mt-3"><a href="http://fhirvine.sudo-fhir.au/api-docs" class="btn btn-primary">View API Documentation</a></p>
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back to Home</a> <a href="{{ url_for('index') }}" class="btn btn-secondary">Back to Home</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,5 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Test Client Launch - {{ site_name }}{% endblock %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
@ -12,6 +14,21 @@
<p class="text-muted text-center small mb-4"> <p class="text-muted text-center small mb-4">
Select a registered application to test its SMART on FHIR launch or register a test app. Select a registered application to test its SMART on FHIR launch or register a test app.
</p> </p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if smart_config %}
<h5 class="mb-3">SMART Configuration</h5>
<pre class="border p-3 bg-light" style="max-height: 400px; overflow-y: auto;">
{{ smart_config | tojson(indent=2) }}
</pre>
{% else %}
<p class="text-danger mb-3">Could not load SMART configuration.</p>
{% endif %}
<form method="POST" action="{{ url_for('test_client') }}" novalidate> <form method="POST" action="{{ url_for('test_client') }}" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="mb-3"> <div class="mb-3">

View File

@ -1,6 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_formhelpers.html" import render_field %} {% from "_formhelpers.html" import render_field %}
{% block title %}Test Server - {{ site_name }}{% endblock %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
@ -13,6 +15,21 @@
<p class="text-muted text-center small mb-4"> <p class="text-muted text-center small mb-4">
Test FHIRVINE as an OAuth2 server by simulating an external client. Test FHIRVINE as an OAuth2 server by simulating an external client.
</p> </p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if smart_config %}
<h5 class="mb-3">SMART Configuration</h5>
<pre class="border p-3 bg-light" style="max-height: 400px; overflow-y: auto;">
{{ smart_config | tojson(indent=2) }}
</pre>
{% else %}
<p class="text-danger mb-3">Could not load SMART configuration.</p>
{% endif %}
<form method="POST" action="{{ url_for('test_server') }}" novalidate> <form method="POST" action="{{ url_for('test_server') }}" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ render_field(form.client_id) }} {{ render_field(form.client_id) }}

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Test SMART Configuration - {{ site_name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="text-center mb-0">Test SMART Configuration</h2>
</div>
<div class="card-body">
<p class="text-muted text-center small mb-4">
Fetch and display the SMART on FHIR configuration from <code>/oauth2/.well-known/smart-configuration</code>.
</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if config_data %}
<h5>SMART Configuration</h5>
<pre class="border p-3 bg-light" style="max-height: 400px; overflow-y: auto;">
{{ config_data | tojson(indent=2) }}
</pre>
{% else %}
<p class="text-danger">No configuration data available. Please try again.</p>
{% endif %}
<div class="d-grid gap-2 mt-4">
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Home</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}